@happyvertical/smrt-svelte 0.43.4 → 0.43.6

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 (92) hide show
  1. package/AGENTS.md +23 -0
  2. package/README.md +23 -1
  3. package/dist/components/forms/AddressInput.svelte +155 -42
  4. package/dist/components/forms/AddressInput.svelte.d.ts.map +1 -1
  5. package/dist/components/forms/CheckboxInput.svelte +40 -13
  6. package/dist/components/forms/CheckboxInput.svelte.d.ts.map +1 -1
  7. package/dist/components/forms/DateRangeInput.svelte +132 -16
  8. package/dist/components/forms/DateRangeInput.svelte.d.ts.map +1 -1
  9. package/dist/components/forms/DateTimeInput.svelte +48 -14
  10. package/dist/components/forms/DateTimeInput.svelte.d.ts.map +1 -1
  11. package/dist/components/forms/Form.svelte +599 -70
  12. package/dist/components/forms/Form.svelte.d.ts +6 -2
  13. package/dist/components/forms/Form.svelte.d.ts.map +1 -1
  14. package/dist/components/forms/MeasurementInput.svelte +137 -31
  15. package/dist/components/forms/MeasurementInput.svelte.d.ts.map +1 -1
  16. package/dist/components/forms/MoneyInput.svelte +42 -23
  17. package/dist/components/forms/MoneyInput.svelte.d.ts.map +1 -1
  18. package/dist/components/forms/NumberInput.svelte +59 -27
  19. package/dist/components/forms/NumberInput.svelte.d.ts.map +1 -1
  20. package/dist/components/forms/PhoneInput.svelte +51 -22
  21. package/dist/components/forms/PhoneInput.svelte.d.ts.map +1 -1
  22. package/dist/components/forms/SelectInput.svelte +38 -22
  23. package/dist/components/forms/SelectInput.svelte.d.ts.map +1 -1
  24. package/dist/components/forms/TextInput.svelte +32 -18
  25. package/dist/components/forms/TextInput.svelte.d.ts.map +1 -1
  26. package/dist/components/forms/TextareaInput.svelte +25 -18
  27. package/dist/components/forms/TextareaInput.svelte.d.ts.map +1 -1
  28. package/dist/components/forms/__tests__/AddressInput.behavior.test.js +94 -0
  29. package/dist/components/forms/__tests__/Form.behavior.test.js +1 -1
  30. package/dist/components/forms/__tests__/Form.extraction.test.js +68 -0
  31. package/dist/components/forms/__tests__/Form.test.js +28 -0
  32. package/dist/components/forms/__tests__/Form.webmcp.test.js +2123 -114
  33. package/dist/components/forms/__tests__/FormMicButton.test.js +0 -1
  34. package/dist/components/forms/__tests__/async-invalid-field.fixture.svelte +30 -0
  35. package/dist/components/forms/__tests__/async-invalid-field.fixture.svelte.d.ts +7 -0
  36. package/dist/components/forms/__tests__/async-invalid-field.fixture.svelte.d.ts.map +1 -0
  37. package/dist/components/forms/__tests__/async-validation-form.fixture.svelte +19 -0
  38. package/dist/components/forms/__tests__/async-validation-form.fixture.svelte.d.ts +9 -0
  39. package/dist/components/forms/__tests__/async-validation-form.fixture.svelte.d.ts.map +1 -0
  40. package/dist/components/forms/__tests__/async-validation-snapshot-form.fixture.svelte +21 -0
  41. package/dist/components/forms/__tests__/async-validation-snapshot-form.fixture.svelte.d.ts +8 -0
  42. package/dist/components/forms/__tests__/async-validation-snapshot-form.fixture.svelte.d.ts.map +1 -0
  43. package/dist/components/forms/__tests__/compound-registration-wrapper.fixture.svelte +27 -0
  44. package/dist/components/forms/__tests__/compound-registration-wrapper.fixture.svelte.d.ts +9 -0
  45. package/dist/components/forms/__tests__/compound-registration-wrapper.fixture.svelte.d.ts.map +1 -0
  46. package/dist/components/forms/__tests__/custom-registration-field.fixture.svelte +48 -0
  47. package/dist/components/forms/__tests__/custom-registration-field.fixture.svelte.d.ts +10 -0
  48. package/dist/components/forms/__tests__/custom-registration-field.fixture.svelte.d.ts.map +1 -0
  49. package/dist/components/forms/__tests__/form-extraction-values.fixture.svelte +26 -0
  50. package/dist/components/forms/__tests__/form-extraction-values.fixture.svelte.d.ts +13 -0
  51. package/dist/components/forms/__tests__/form-extraction-values.fixture.svelte.d.ts.map +1 -0
  52. package/dist/components/forms/__tests__/form-registration-lifecycle.fixture.svelte +52 -0
  53. package/dist/components/forms/__tests__/form-registration-lifecycle.fixture.svelte.d.ts +16 -0
  54. package/dist/components/forms/__tests__/form-registration-lifecycle.fixture.svelte.d.ts.map +1 -0
  55. package/dist/components/forms/__tests__/form-with-fields.fixture.svelte +108 -5
  56. package/dist/components/forms/__tests__/form-with-fields.fixture.svelte.d.ts +34 -0
  57. package/dist/components/forms/__tests__/form-with-fields.fixture.svelte.d.ts.map +1 -1
  58. package/dist/components/forms/__tests__/form-with-policy-field.fixture.svelte +24 -0
  59. package/dist/components/forms/__tests__/form-with-policy-field.fixture.svelte.d.ts +11 -0
  60. package/dist/components/forms/__tests__/form-with-policy-field.fixture.svelte.d.ts.map +1 -0
  61. package/dist/components/forms/__tests__/form-with-structured-fields.fixture.svelte +69 -4
  62. package/dist/components/forms/__tests__/form-with-structured-fields.fixture.svelte.d.ts +32 -0
  63. package/dist/components/forms/__tests__/form-with-structured-fields.fixture.svelte.d.ts.map +1 -1
  64. package/dist/components/forms/__tests__/legacy-form-context.fixture.svelte +44 -0
  65. package/dist/components/forms/__tests__/legacy-form-context.fixture.svelte.d.ts +9 -0
  66. package/dist/components/forms/__tests__/legacy-form-context.fixture.svelte.d.ts.map +1 -0
  67. package/dist/components/forms/__tests__/policy-field.fixture.svelte +35 -0
  68. package/dist/components/forms/__tests__/policy-field.fixture.svelte.d.ts +8 -0
  69. package/dist/components/forms/__tests__/policy-field.fixture.svelte.d.ts.map +1 -0
  70. package/dist/components/forms/prepare-field-value.d.ts +5 -0
  71. package/dist/components/forms/prepare-field-value.d.ts.map +1 -0
  72. package/dist/components/forms/prepare-field-value.js +15 -0
  73. package/dist/state/form-context.d.ts +28 -6
  74. package/dist/state/form-context.d.ts.map +1 -1
  75. package/dist/state/form-context.js +75 -7
  76. package/dist/web/__tests__/webmcp-composed.child.fixture.svelte +62 -0
  77. package/dist/web/__tests__/webmcp-composed.child.fixture.svelte.d.ts +8 -0
  78. package/dist/web/__tests__/webmcp-composed.child.fixture.svelte.d.ts.map +1 -0
  79. package/dist/web/__tests__/webmcp-composed.fixture.svelte +52 -0
  80. package/dist/web/__tests__/webmcp-composed.fixture.svelte.d.ts +12 -0
  81. package/dist/web/__tests__/webmcp-composed.fixture.svelte.d.ts.map +1 -0
  82. package/dist/web/__tests__/webmcp-composed.integration.svelte.test.js +247 -0
  83. package/dist/web/__tests__/webmcp-ssr.fixture.svelte +13 -0
  84. package/dist/web/__tests__/webmcp-ssr.fixture.svelte.d.ts +19 -0
  85. package/dist/web/__tests__/webmcp-ssr.fixture.svelte.d.ts.map +1 -0
  86. package/dist/web/__tests__/webmcp-ui.test.js +209 -33
  87. package/dist/web/__tests__/webmcp.test.js +20 -0
  88. package/dist/web/webmcp-ui.d.ts.map +1 -1
  89. package/dist/web/webmcp-ui.js +50 -31
  90. package/dist/web/webmcp.svelte.d.ts.map +1 -1
  91. package/dist/web/webmcp.svelte.js +6 -1
  92. 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';
@@ -39,7 +43,7 @@ export interface Props {
39
43
  llmModel?: LLMModelId;
40
44
  /** Called when form is submitted (if provided, prevents native submission) */
41
45
  onsubmit?: (data: Record<string, unknown>) => void | Promise<void>;
42
- /** Expose this form's submit intent to WebMCP. */
46
+ /** Let WebMCP propose field changes for local human review. */
43
47
  webmcp?: boolean | { name?: string; description?: string };
44
48
  /** Collection/model identity used when naming the generated intent. */
45
49
  collection?: string;
@@ -49,8 +53,12 @@ export interface Props {
49
53
  action?: string;
50
54
  /** Stable identity used by control/agent interaction adapters. */
51
55
  formId?: string;
56
+ /** Default record-qualified identity for registered fields. */
57
+ subject?: ControlSubject;
52
58
  interactionRegistry?: ControlInteractionRegistry;
53
59
  oninteraction?: (event: ControlInteractionEvent) => void;
60
+ /** Render the built-in human review surface for staged changes. */
61
+ stagedReview?: boolean;
54
62
  id?: string;
55
63
  name?: string;
56
64
  class?: string;
@@ -69,8 +77,10 @@ const {
69
77
  method,
70
78
  action,
71
79
  formId,
80
+ subject,
72
81
  interactionRegistry,
73
82
  oninteraction,
83
+ stagedReview = true,
74
84
  id,
75
85
  name,
76
86
  class: className = '',
@@ -90,12 +100,18 @@ const resolvedInteractionRegistry = $derived(
90
100
  : undefined) ??
91
101
  localInteractionRegistry,
92
102
  );
93
- const interactionDisposers = new Map<
94
- string,
95
- { field: FieldDefinition; dispose: () => void }
96
- >();
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>();
97
112
  let activeInteractionRegistry: ControlInteractionRegistry | undefined;
98
113
  let activeInteractionFormId: string | undefined;
114
+ let formElement = $state<HTMLFormElement | null>(null);
99
115
 
100
116
  setControlInteractionContext({
101
117
  get formId() {
@@ -120,6 +136,11 @@ $effect(() => {
120
136
 
121
137
  // Internal state
122
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
+ >();
123
144
  let isFormListening = $state(false);
124
145
  let isExtracting = $state(false);
125
146
  let spokenText = $state('');
@@ -150,41 +171,295 @@ function interactionKind(field: FieldDefinition): ControlKind {
150
171
  }
151
172
  }
152
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
+
153
308
  function registerInteraction(
154
309
  registry: ControlInteractionRegistry,
155
310
  currentFormId: string,
156
311
  field: FieldDefinition,
157
- ): (() => void) | undefined {
312
+ resolvedSubject: ControlSubject | undefined,
313
+ previous?: InteractionRegistration,
314
+ ): InteractionRegistration | undefined {
158
315
  const identity = {
159
316
  formId: currentFormId,
160
317
  controlId: field.controlId ?? field.name,
318
+ subject: resolvedSubject,
161
319
  };
162
- if (registry.get(identity)) {
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) {
163
324
  logger.warn('Form: duplicate interaction identity rejected', { identity });
164
325
  return undefined;
165
326
  }
166
- return registry.register({
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,
167
421
  identity,
168
- metadata: {
169
- kind: interactionKind(field),
170
- label: field.label,
171
- description: field.description,
172
- sensitivity: field.sensitivity ?? 'public',
173
- readable: field.readable,
174
- writable: field.writable,
175
- constraints: field.constraints,
176
- options: field.options,
177
- unit: field.unit,
178
- },
179
- getValue: field.getValue,
180
- setValue: field.setValue,
181
- clear: field.clear ?? (() => field.setValue('')),
182
- focus: field.focus,
183
- reveal: field.reveal,
184
- highlight: field.highlight,
185
- validate: field.validate,
186
- getState: field.getState,
187
- });
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
+ );
188
463
  }
189
464
 
190
465
  let interactionRegistryRevision = $state(0);
@@ -192,8 +467,13 @@ let interactionRegistryRevision = $state(0);
192
467
  $effect(() => {
193
468
  const registry = resolvedInteractionRegistry;
194
469
  return registry.subscribe((event) => {
195
- if (event.type === 'registered' || event.type === 'unregistered') {
196
- interactionRegistryRevision += 1;
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;
197
477
  }
198
478
  });
199
479
  });
@@ -202,54 +482,146 @@ $effect(() => {
202
482
  const registry = resolvedInteractionRegistry;
203
483
  const currentFormId = resolvedFormId;
204
484
  const currentFields = new Map(fields);
485
+ const currentOwnershipKey = Array.from(currentFields.keys())
486
+ .sort()
487
+ .join('\0');
205
488
  void interactionRegistryRevision;
206
489
  if (
207
490
  activeInteractionRegistry !== registry ||
208
491
  activeInteractionFormId !== currentFormId
209
492
  ) {
210
493
  for (const registration of interactionDisposers.values()) {
211
- registration.dispose();
494
+ disposeInteraction(registration);
212
495
  }
213
496
  interactionDisposers.clear();
214
497
  activeInteractionRegistry = registry;
215
498
  activeInteractionFormId = currentFormId;
216
499
  }
217
500
  for (const [name, registration] of interactionDisposers) {
218
- const identity = {
219
- formId: currentFormId,
220
- controlId: registration.field.controlId ?? registration.field.name,
221
- };
501
+ const nextField = currentFields.get(name);
502
+ if (!nextField) {
503
+ disposeInteraction(registration);
504
+ interactionDisposers.delete(name);
505
+ continue;
506
+ }
507
+ const nextSubject = resolvedFieldSubject(nextField);
222
508
  if (
223
- currentFields.get(name) !== registration.field ||
224
- !registry.get(identity)
509
+ nextField === registration.field &&
510
+ sameSubject(registration.subject, nextSubject) &&
511
+ registry.get(registration.identity)
225
512
  ) {
226
- registration.dispose();
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);
227
522
  interactionDisposers.delete(name);
228
523
  }
229
524
  }
230
525
  for (const [name, field] of currentFields) {
231
- if (interactionDisposers.has(name)) continue;
232
- const dispose = registerInteraction(registry, currentFormId, field);
233
- if (!dispose) continue;
234
- interactionDisposers.set(name, {
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,
235
538
  field,
236
- dispose,
237
- });
539
+ resolvedSubject,
540
+ previous,
541
+ );
542
+ if (!registration) continue;
543
+ interactionDisposers.set(name, registration);
238
544
  }
239
545
  });
240
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
+
241
569
  // Create form context
242
570
  const formContext: SMRTFormContext = {
243
571
  get mode() {
244
572
  return app.state.mode === 'smrt' ? 'smrt' : 'default';
245
573
  },
246
574
  registerField(field: FieldDefinition) {
247
- fields.set(field.name, field);
248
- fields = new Map(fields); // Trigger reactivity
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
+ };
249
607
  },
250
608
  unregisterField(name: string) {
251
- fields.delete(name);
252
- 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);
253
625
  },
254
626
  getFieldSchema() {
255
627
  return Array.from(fields.values());
@@ -278,6 +650,7 @@ function webMcpFieldSchema(field: FieldDefinition): Record<string, unknown> {
278
650
  case 'measurement':
279
651
  schema = {
280
652
  type: 'object',
653
+ additionalProperties: false,
281
654
  properties: {
282
655
  value: {
283
656
  type: 'number',
@@ -301,6 +674,7 @@ function webMcpFieldSchema(field: FieldDefinition): Record<string, unknown> {
301
674
  case 'daterange':
302
675
  schema = {
303
676
  type: 'object',
677
+ additionalProperties: false,
304
678
  properties: {
305
679
  startDate: { type: 'string' },
306
680
  endDate: { type: 'string' },
@@ -313,6 +687,7 @@ function webMcpFieldSchema(field: FieldDefinition): Record<string, unknown> {
313
687
  case 'address':
314
688
  schema = {
315
689
  type: 'object',
690
+ additionalProperties: false,
316
691
  properties: {
317
692
  street: { type: 'string' },
318
693
  city: { type: 'string' },
@@ -334,9 +709,14 @@ function webMcpFieldSchema(field: FieldDefinition): Record<string, unknown> {
334
709
  };
335
710
  break;
336
711
  case 'number':
337
- case 'money':
338
712
  schema = { type: 'number' };
339
713
  break;
714
+ case 'money':
715
+ schema = {
716
+ type: 'integer',
717
+ description: 'Amount in minor currency units (cents)',
718
+ };
719
+ break;
340
720
  case 'checkbox':
341
721
  schema = { type: 'boolean' };
342
722
  break;
@@ -346,14 +726,25 @@ function webMcpFieldSchema(field: FieldDefinition): Record<string, unknown> {
346
726
  }
347
727
 
348
728
  if (field.label) schema.title = field.label;
349
- 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
+ }
350
735
  if (field.options) {
351
736
  schema.enum = field.options.map((option) => option.value);
352
737
  }
353
- if (schema.type === 'number' && field.constraints?.min !== undefined) {
738
+ if (
739
+ (schema.type === 'number' || schema.type === 'integer') &&
740
+ field.constraints?.min !== undefined
741
+ ) {
354
742
  schema.minimum = Number(field.constraints.min);
355
743
  }
356
- if (schema.type === 'number' && field.constraints?.max !== undefined) {
744
+ if (
745
+ (schema.type === 'number' || schema.type === 'integer') &&
746
+ field.constraints?.max !== undefined
747
+ ) {
357
748
  schema.maximum = Number(field.constraints.max);
358
749
  }
359
750
  if (schema.type === 'string' && field.constraints?.minLength !== undefined) {
@@ -368,17 +759,75 @@ function webMcpFieldSchema(field: FieldDefinition): Record<string, unknown> {
368
759
  return schema;
369
760
  }
370
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
+
371
810
  function formInputSchema(): Record<string, unknown> {
811
+ void interactionRegistryRevision;
372
812
  const properties: Record<string, unknown> = {};
373
813
  const required: string[] = [];
374
814
 
375
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;
376
824
  properties[field.name] = webMcpFieldSchema(field);
377
825
  if (field.constraints?.required) required.push(field.name);
378
826
  }
379
827
 
380
828
  return {
381
829
  type: 'object',
830
+ additionalProperties: false,
382
831
  properties,
383
832
  ...(required.length > 0 ? { required } : {}),
384
833
  };
@@ -387,16 +836,15 @@ function formInputSchema(): Record<string, unknown> {
387
836
  // Provide context to children
388
837
  setFormContext(formContext);
389
838
 
390
- async function submitForWebMcp(args: Record<string, unknown>): Promise<string> {
391
- // Stage tool arguments through the same field setters used by native input
392
- // controls, then run the shared validation and submit path.
393
- for (const [name, value] of Object.entries(args)) {
394
- fields.get(name)?.setValue(value);
395
- }
396
-
839
+ async function submitCurrentForm(): Promise<string> {
397
840
  const data: Record<string, unknown> = {};
398
841
  let valid = true;
399
- 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) {
400
848
  data[name] = field.getValue();
401
849
  if (
402
850
  field.constraints?.required &&
@@ -404,9 +852,15 @@ async function submitForWebMcp(args: Record<string, unknown>): Promise<string> {
404
852
  ) {
405
853
  valid = false;
406
854
  }
855
+ }
856
+
857
+ for (const [, field] of fieldSnapshot) {
407
858
  if (field.validate) {
408
859
  try {
409
- valid = field.validate() && valid;
860
+ const validation = field.validate();
861
+ valid =
862
+ (typeof validation === 'boolean' ? validation : await validation) &&
863
+ valid;
410
864
  } catch {
411
865
  valid = false;
412
866
  }
@@ -419,17 +873,73 @@ async function submitForWebMcp(args: Record<string, unknown>): Promise<string> {
419
873
  return 'Submitted successfully';
420
874
  }
421
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
+
422
932
  useWebMcpTool(() => {
423
933
  if (!webmcp) return null;
424
934
  const options = typeof webmcp === 'object' ? webmcp : {};
425
935
  return {
426
- name: options.name ?? `${collection ?? resolvedFormId}_submit`,
936
+ name: options.name ?? `${collection ?? resolvedFormId}_stage_changes`,
427
937
  description:
428
938
  options.description ??
429
- `Submit the ${collection ?? name ?? resolvedFormId} form after validating its fields`,
939
+ `Propose changes to the ${collection ?? name ?? resolvedFormId} form for local human review`,
430
940
  inputSchema: formInputSchema(),
431
941
  annotations: { readOnlyHint: false },
432
- execute: submitForWebMcp,
942
+ execute: stageForWebMcp,
433
943
  };
434
944
  });
435
945
 
@@ -550,12 +1060,24 @@ async function extractFields(text: string): Promise<Record<string, unknown>> {
550
1060
  }
551
1061
 
552
1062
  // Apply extracted values to fields with cleanup
553
- function applyExtractedValues(values: Record<string, unknown>) {
1063
+ async function applyExtractedValues(values: Record<string, unknown>) {
554
1064
  for (const [name, value] of Object.entries(values)) {
555
1065
  const field = fields.get(name);
556
1066
  if (field && value !== undefined && value !== null) {
557
1067
  const cleanedValue = cleanValue(value, field.type);
558
- 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
+ }
559
1081
  }
560
1082
  }
561
1083
  }
@@ -815,7 +1337,7 @@ async function stopFormListening() {
815
1337
 
816
1338
  try {
817
1339
  const values = await extractFields(textToExtract);
818
- applyExtractedValues(values);
1340
+ await applyExtractedValues(values);
819
1341
  } catch (err) {
820
1342
  extractError =
821
1343
  err instanceof Error ? err.message : 'Failed to extract fields';
@@ -838,7 +1360,7 @@ onDestroy(() => {
838
1360
  }
839
1361
  stopAudioLevelMonitoring();
840
1362
  for (const registration of interactionDisposers.values()) {
841
- registration.dispose();
1363
+ disposeInteraction(registration);
842
1364
  }
843
1365
  interactionDisposers.clear();
844
1366
  });
@@ -853,7 +1375,7 @@ function handleSubmit(e: Event) {
853
1375
  // This allows native form submission for SvelteKit form actions
854
1376
  if (onsubmit) {
855
1377
  e.preventDefault();
856
- void submitForWebMcp({}).catch((error) => {
1378
+ void submitCurrentForm().catch((error) => {
857
1379
  logger.error('Form submit failed', { error });
858
1380
  });
859
1381
  }
@@ -874,6 +1396,7 @@ export function getInteractionRegistry(): ControlInteractionRegistry {
874
1396
  </script>
875
1397
 
876
1398
  <form
1399
+ bind:this={formElement}
877
1400
  {id}
878
1401
  {name}
879
1402
  class="smrt-form {className}"
@@ -984,6 +1507,12 @@ export function getInteractionRegistry(): ControlInteractionRegistry {
984
1507
  <div class="form-fields">
985
1508
  {@render children()}
986
1509
  </div>
1510
+ <StagedControlReview
1511
+ registry={resolvedInteractionRegistry}
1512
+ formId={resolvedFormId}
1513
+ {formElement}
1514
+ summary={stagedReview}
1515
+ />
987
1516
  </form>
988
1517
 
989
1518
  {#if isFormListening && spokenText}