@happyvertical/smrt-svelte 0.43.4 → 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 (92) hide show
  1. package/AGENTS.md +23 -0
  2. package/README.md +9 -0
  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
package/AGENTS.md CHANGED
@@ -19,6 +19,13 @@ subpath you are editing. This file keeps what holds in every module.
19
19
  | `src/web/webmcp-provider.ts` | Provider config for generated data/model WebMCP tools: definitions, effect policy, namespace, budget, legacy/canonical filters, and fetcher seams (#2520) | — |
20
20
  | `src/web/webmcp-ui.ts` | Fixed, low-cardinality WebMCP adapter over the Provider's mounted form-control and data-surface registries (#2521) | — |
21
21
 
22
+ The composed WebMCP fixture in
23
+ `src/web/__tests__/webmcp-composed.integration.svelte.test.ts` mounts a real
24
+ Provider, rich Form, DataTable/DataSurface, and a bespoke `useWebMcpTool`
25
+ component intent. Preserve its lifecycle assertions: SSR and missing
26
+ `document.modelContext` are no-ops, mounted tools are fixed-cardinality, and
27
+ all registrations abort when their owning component unmounts.
28
+
22
29
  ## The UI split — primitive-adoption contract (#1589)
23
30
 
24
31
  SMRT's shared UI primitives are split by concern: **`smrt-ui` owns the
@@ -55,6 +62,17 @@ migration; flips strict per package as it adopts the primitives).
55
62
 
56
63
  Wraps app in `+layout.svelte`. Provides auth state, permissions, WebSocket, and AI capabilities.
57
64
 
65
+ The rich `Form` uses smrt-ui's shared staged-edit review contract. Agent
66
+ proposals never mutate field bindings directly: they remain staged until a
67
+ human applies or discards them in the form's local review surface. Keep this
68
+ state machine in smrt-ui; smrt-svelte contributes Provider-backed field
69
+ registration and consumes the shared component rather than duplicating it.
70
+ The generated WebMCP form tool proposes registry stages; it never writes or
71
+ submits field values directly.
72
+ Rich `FieldDefinition` registrations may carry a `subject` for record-qualified
73
+ identity. The Form bridge also folds live DOM disabled/read-only state into the
74
+ registry and omits those fields from WebMCP schemas and staging.
75
+
58
76
  ```svelte
59
77
  <script>
60
78
  let { data, children } = $props();
@@ -100,6 +118,11 @@ adapter for a prefix; configure distinct prefixes for intentional coexistence.
100
118
 
101
119
  ## AI System
102
120
 
121
+ - Rich Form WebMCP tools only stage review proposals. Structured payloads are
122
+ limited to published schema properties, money uses safe integer minor units,
123
+ and injected legacy registries without `executeBatch` use ordered `execute`
124
+ calls.
125
+
103
126
  - **Preload strategies**: `none`, `eager`, `idle` (recommended), `on-visible`
104
127
  - **Warm client cache**: module-level Map survives navigation/remounts -- avoids re-downloading WASM/models
105
128
  - **Adapters**: STT (browser-speech, whisper-cpp, whisper-wasm), TTS (browser-synthesis), LLM (webllm, transformers-llm)
package/README.md CHANGED
@@ -156,6 +156,15 @@ hidden data-surface columns are not serialized. Read responses are marked as
156
156
  untrusted content. Bespoke `useWebMcpTool` and `<Form webmcp>` tools retain their
157
157
  existing lifecycle and submit behavior.
158
158
 
159
+ Custom rich fields may continue to call `registerField(field)` and later
160
+ `unregisterField(name)`. New code should retain and invoke the disposer returned
161
+ by `registerField`: it is bound to that exact registration, so cleanup cannot
162
+ remove a same-name replacement. The return value is additive; legacy form
163
+ contexts whose `registerField` returns `void` remain supported. Context accessors
164
+ bind legacy name-based cleanup to registrations made by that caller, so
165
+ overlapping same-name fields can unmount in either order without retaining a
166
+ detached control.
167
+
159
168
  ### Form Components
160
169
 
161
170
  ```svelte
@@ -1,12 +1,16 @@
1
1
  <script lang="ts">
2
2
  import { useI18n } from '@happyvertical/smrt-ui/i18n';
3
- import { onDestroy, onMount } from 'svelte';
3
+ import { untrack } from 'svelte';
4
4
  import { useAppState } from '../../hooks/useAppState.svelte.js';
5
5
  import { M } from '../../i18n/strings.forms.js';
6
6
  import {
7
7
  type FieldDefinition,
8
8
  tryGetFormContext,
9
9
  } from '../../state/form-context.js';
10
+ import {
11
+ invalidStagedValue,
12
+ prepareTextFieldValue,
13
+ } from './prepare-field-value.js';
10
14
  import type { AddressValue } from './types.js';
11
15
 
12
16
  const { t } = useI18n();
@@ -80,8 +84,66 @@ let {
80
84
  onchange,
81
85
  }: Props = $props();
82
86
 
87
+ function countryValues(): string[] {
88
+ return [...new Set(countries.map((option) => option.value))];
89
+ }
90
+
91
+ function provinceValues(): string[] {
92
+ return [
93
+ ...new Set([
94
+ ...(!required ? [''] : []),
95
+ ...provinces.map((option) => option.value),
96
+ ]),
97
+ ];
98
+ }
99
+
100
+ function schemaForField(field: AddressField): Record<string, unknown> {
101
+ if (field === 'country') return { type: 'string', enum: countryValues() };
102
+ if (field === 'province') return { type: 'string', enum: provinceValues() };
103
+ return { type: 'string' };
104
+ }
105
+
106
+ function validatesAddressMembers(candidate: unknown): boolean {
107
+ if (typeof candidate !== 'object' || Array.isArray(candidate)) return false;
108
+ const address = candidate as Record<string, unknown>;
109
+ if (
110
+ ![Object.prototype, null].includes(Object.getPrototypeOf(address)) ||
111
+ !Object.keys(address).every((key) =>
112
+ fields.includes(key as AddressField),
113
+ ) ||
114
+ !fields.every(
115
+ (field) =>
116
+ !Object.hasOwn(address, field) || typeof address[field] === 'string',
117
+ )
118
+ ) {
119
+ return false;
120
+ }
121
+ if (
122
+ (Object.hasOwn(address, 'country') &&
123
+ !countryValues().includes(address.country as string)) ||
124
+ (Object.hasOwn(address, 'province') &&
125
+ !provinceValues().includes(address.province as string))
126
+ ) {
127
+ return false;
128
+ }
129
+ return true;
130
+ }
131
+
132
+ function validatesAddressCandidate(candidate: unknown): boolean {
133
+ if (candidate === null || candidate === undefined) return !required;
134
+ if (!validatesAddressMembers(candidate)) return false;
135
+ if (!required) return true;
136
+ const address = candidate as Record<string, unknown>;
137
+ return fields.every(
138
+ (field) =>
139
+ typeof address[field] === 'string' && address[field].trim().length > 0,
140
+ );
141
+ }
142
+
83
143
  const app = useAppState();
84
144
  const formContext = tryGetFormContext();
145
+ const fieldOwnerId = $props.id();
146
+ const fieldOwnerToken = `smrt-rich-field-${fieldOwnerId}`;
85
147
 
86
148
  const isSmrt = $derived(app.state.mode === 'smrt');
87
149
 
@@ -90,7 +152,7 @@ let street = $state(value.street ?? '');
90
152
  let city = $state(value.city ?? '');
91
153
  let province = $state(value.province ?? '');
92
154
  let postalCode = $state(value.postalCode ?? '');
93
- let country = $state(value.country ?? 'CA');
155
+ let country = $state(value.country ?? untrack(() => countries[0]?.value ?? ''));
94
156
 
95
157
  // Keep in sync with external value changes
96
158
  $effect(() => {
@@ -105,13 +167,18 @@ $effect(() => {
105
167
  country = value.country;
106
168
  });
107
169
 
108
- function updateValue() {
170
+ function currentValue(): Partial<AddressValue> {
109
171
  const newValue: Partial<AddressValue> = {};
110
172
  if (fields.includes('street')) newValue.street = street;
111
173
  if (fields.includes('city')) newValue.city = city;
112
174
  if (fields.includes('province')) newValue.province = province;
113
175
  if (fields.includes('postalCode')) newValue.postalCode = postalCode;
114
176
  if (fields.includes('country')) newValue.country = country;
177
+ return newValue;
178
+ }
179
+
180
+ function updateValue() {
181
+ const newValue = currentValue();
115
182
  value = newValue;
116
183
  onchange?.(newValue);
117
184
  }
@@ -141,11 +208,16 @@ function parseSpokenAddress(text: string): Partial<AddressValue> {
141
208
  }
142
209
  }
143
210
 
144
- // Try to extract country
145
- if (/\bcanada\b/i.test(normalized)) {
146
- result.country = 'CA';
147
- } else if (/\bunited states\b|\busa\b|\bu\.?s\.?a?\.?\b/i.test(normalized)) {
148
- result.country = 'US';
211
+ // Try to extract a currently configured country by its label or value.
212
+ for (const option of countries) {
213
+ const matches = [option.label, option.value].some((candidate) => {
214
+ const escaped = candidate.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
215
+ return new RegExp(`(^|\\W)${escaped}(?=$|\\W)`, 'i').test(normalized);
216
+ });
217
+ if (matches) {
218
+ result.country = option.value;
219
+ break;
220
+ }
149
221
  }
150
222
 
151
223
  // Common city extraction patterns
@@ -192,26 +264,33 @@ const primaryFieldId = $derived.by(() => {
192
264
  });
193
265
 
194
266
  // Register with form context
195
- onMount(() => {
267
+ $effect(() => {
196
268
  if (formContext) {
269
+ const registeredName = name;
197
270
  const fieldDef: FieldDefinition = {
198
- name,
271
+ name: registeredName,
272
+ ownerToken: fieldOwnerToken,
199
273
  type: 'address',
200
- label,
201
- description:
202
- description ||
203
- 'A mailing address with street, city, province, postal code, and country',
274
+ get label() {
275
+ return label;
276
+ },
277
+ get description() {
278
+ return (
279
+ description ||
280
+ 'A mailing address with street, city, province, postal code, and country'
281
+ );
282
+ },
204
283
  setValue: (v: unknown) => {
205
284
  if (v === null || v === undefined) {
206
285
  street = '';
207
286
  city = '';
208
287
  province = '';
209
288
  postalCode = '';
210
- country = 'CA';
289
+ country = countries[0]?.value ?? '';
211
290
  updateValue();
212
291
  return;
213
292
  }
214
- if (typeof v === 'object' && v !== null) {
293
+ if (validatesAddressMembers(v)) {
215
294
  const addr = v as Partial<AddressValue>;
216
295
  if (addr.street !== undefined) street = addr.street;
217
296
  if (addr.city !== undefined) city = addr.city;
@@ -222,36 +301,70 @@ onMount(() => {
222
301
  return;
223
302
  }
224
303
  // Try to parse spoken address
225
- const parsed = parseSpokenAddress(String(v));
226
- if (Object.keys(parsed).length > 0) {
227
- if (parsed.street) street = parsed.street;
228
- if (parsed.city) city = parsed.city;
229
- if (parsed.province) province = parsed.province;
230
- if (parsed.postalCode) postalCode = parsed.postalCode;
231
- if (parsed.country) country = parsed.country;
304
+ const parsed = Object.fromEntries(
305
+ Object.entries(parseSpokenAddress(String(v))).filter(([field]) =>
306
+ fields.includes(field as AddressField),
307
+ ),
308
+ ) as Partial<AddressValue>;
309
+ if (Object.keys(parsed).length > 0 && validatesAddressMembers(parsed)) {
310
+ if (parsed.street !== undefined) street = parsed.street;
311
+ if (parsed.city !== undefined) city = parsed.city;
312
+ if (parsed.province !== undefined) province = parsed.province;
313
+ if (parsed.postalCode !== undefined) postalCode = parsed.postalCode;
314
+ if (parsed.country !== undefined) country = parsed.country;
232
315
  updateValue();
233
316
  }
234
317
  },
235
- getValue: () => value,
236
- constraints: { required },
237
- webMcpSchema: {
238
- type: 'object',
239
- properties: Object.fromEntries(
240
- fields.map((field) => [field, { type: 'string' }]),
241
- ),
242
- ...(required ? { required: [...fields] } : {}),
318
+ getValue: currentValue,
319
+ prepareValue: (candidate) => {
320
+ if (candidate === null || candidate === undefined) {
321
+ return Object.fromEntries(
322
+ fields.map((field) => [
323
+ field,
324
+ field === 'country' ? (countries[0]?.value ?? '') : '',
325
+ ]),
326
+ );
327
+ }
328
+ if (typeof candidate === 'object' && !Array.isArray(candidate)) {
329
+ return candidate;
330
+ }
331
+ const parsed = parseSpokenAddress(prepareTextFieldValue(candidate));
332
+ const configured = Object.fromEntries(
333
+ Object.entries(parsed).filter(([field]) =>
334
+ fields.includes(field as AddressField),
335
+ ),
336
+ );
337
+ return Object.keys(configured).length > 0
338
+ ? { ...currentValue(), ...configured }
339
+ : invalidStagedValue();
340
+ },
341
+ clear: () => {
342
+ street = '';
343
+ city = '';
344
+ province = '';
345
+ postalCode = '';
346
+ country = countries[0]?.value ?? '';
347
+ updateValue();
348
+ return true;
349
+ },
350
+ getState: () => ({ disabled }),
351
+ get constraints() {
352
+ return { required };
353
+ },
354
+ get webMcpSchema() {
355
+ return {
356
+ type: 'object',
357
+ additionalProperties: false,
358
+ properties: Object.fromEntries(
359
+ fields.map((field) => [field, schemaForField(field)]),
360
+ ),
361
+ ...(required ? { required: [...fields] } : {}),
362
+ };
243
363
  },
244
- validate: () =>
245
- !required ||
246
- fields.every((field) => String(value[field] ?? '').trim().length > 0),
364
+ validateValue: validatesAddressCandidate,
365
+ validate: () => validatesAddressCandidate(currentValue()),
247
366
  };
248
- formContext.registerField(fieldDef);
249
- }
250
- });
251
-
252
- onDestroy(() => {
253
- if (formContext) {
254
- formContext.unregisterField(name);
367
+ return formContext.registerField(fieldDef);
255
368
  }
256
369
  });
257
370
 
@@ -281,7 +394,7 @@ function handleCountryChange(e: Event) {
281
394
  }
282
395
  </script>
283
396
 
284
- <div class="smrt-address">
397
+ <div class="smrt-address" data-smrt-field-owner={fieldOwnerToken}>
285
398
  {#if label}
286
399
  <label class="smrt-label" for={primaryFieldId}>
287
400
  {label}
@@ -1 +1 @@
1
- {"version":3,"file":"AddressInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/AddressInput.svelte.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAG/C,KAAK,YAAY,GAAG,MAAM,YAAY,CAAC;AAEvC,MAAM,WAAW,KAAK;IACpB,wBAAwB;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,kBAAkB;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uCAAuC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+BAA+B;IAC/B,KAAK,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,CAAC;IAC9B,2BAA2B;IAC3B,MAAM,CAAC,EAAE,YAAY,EAAE,CAAC;IACxB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,+BAA+B;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mCAAmC;IACnC,SAAS,CAAC,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACpD,0CAA0C;IAC1C,SAAS,CAAC,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACpD,gCAAgC;IAChC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,CAAC,YAAY,CAAC,KAAK,IAAI,CAAC;CACnD;AAmUD,QAAA,MAAM,YAAY,gDAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
1
+ {"version":3,"file":"AddressInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/AddressInput.svelte.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAG/C,KAAK,YAAY,GAAG,MAAM,YAAY,CAAC;AAEvC,MAAM,WAAW,KAAK;IACpB,wBAAwB;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,kBAAkB;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uCAAuC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+BAA+B;IAC/B,KAAK,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,CAAC;IAC9B,2BAA2B;IAC3B,MAAM,CAAC,EAAE,YAAY,EAAE,CAAC;IACxB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,+BAA+B;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mCAAmC;IACnC,SAAS,CAAC,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACpD,0CAA0C;IAC1C,SAAS,CAAC,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACpD,gCAAgC;IAChC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,CAAC,YAAY,CAAC,KAAK,IAAI,CAAC;CACnD;AAibD,QAAA,MAAM,YAAY,gDAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
@@ -1,12 +1,12 @@
1
1
  <script lang="ts">
2
2
  import { ripple } from '@happyvertical/smrt-ui';
3
3
  import { parseSpokenBoolean } from '@happyvertical/smrt-ui/utils/forms/formatters.js';
4
- import { onDestroy, onMount } from 'svelte';
5
4
  import { useAppState } from '../../hooks/useAppState.svelte.js';
6
5
  import {
7
6
  type FieldDefinition,
8
7
  tryGetFormContext,
9
8
  } from '../../state/form-context.js';
9
+ import { invalidStagedValue } from './prepare-field-value.js';
10
10
 
11
11
  export interface Props {
12
12
  /** Field name */
@@ -46,13 +46,18 @@ function updateValue(newValue: boolean) {
46
46
  }
47
47
 
48
48
  // Register with form context
49
- onMount(() => {
49
+ $effect(() => {
50
50
  if (formContext) {
51
+ const registeredName = name;
51
52
  const fieldDef: FieldDefinition = {
52
- name,
53
+ name: registeredName,
53
54
  type: 'checkbox',
54
- label,
55
- description: description || `Yes or no for ${label || name}`,
55
+ get label() {
56
+ return label;
57
+ },
58
+ get description() {
59
+ return description || `Yes or no for ${label || name}`;
60
+ },
56
61
  setValue: (v: unknown) => {
57
62
  if (typeof v === 'boolean') {
58
63
  updateValue(v);
@@ -64,16 +69,38 @@ onMount(() => {
64
69
  }
65
70
  },
66
71
  getValue: () => checked,
67
- constraints: { required },
72
+ prepareValue: (candidate) => {
73
+ if (typeof candidate === 'boolean') return candidate;
74
+ if (
75
+ candidate !== null &&
76
+ candidate !== undefined &&
77
+ typeof candidate !== 'string' &&
78
+ typeof candidate !== 'number'
79
+ ) {
80
+ return invalidStagedValue();
81
+ }
82
+ return (
83
+ parseSpokenBoolean(String(candidate ?? '')) ?? invalidStagedValue()
84
+ );
85
+ },
86
+ clear: () => {
87
+ updateValue(false);
88
+ return true;
89
+ },
90
+ getState: () => ({ disabled }),
91
+ get constraints() {
92
+ return { required };
93
+ },
94
+ validateValue: (candidate) => {
95
+ const next =
96
+ typeof candidate === 'boolean'
97
+ ? candidate
98
+ : parseSpokenBoolean(String(candidate ?? ''));
99
+ return next !== null && (!required || next);
100
+ },
68
101
  validate: () => !required || checked,
69
102
  };
70
- formContext.registerField(fieldDef);
71
- }
72
- });
73
-
74
- onDestroy(() => {
75
- if (formContext) {
76
- formContext.unregisterField(name);
103
+ return formContext.registerField(fieldDef);
77
104
  }
78
105
  });
79
106
 
@@ -1 +1 @@
1
- {"version":3,"file":"CheckboxInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/CheckboxInput.svelte.ts"],"names":[],"mappings":"AAaA,MAAM,WAAW,KAAK;IACpB,iBAAiB;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,kBAAkB;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uCAAuC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+BAA+B;IAC/B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,gCAAgC;IAChC,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;CACvC;AA2FD,QAAA,MAAM,aAAa,kDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"CheckboxInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/CheckboxInput.svelte.ts"],"names":[],"mappings":"AAaA,MAAM,WAAW,KAAK;IACpB,iBAAiB;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,kBAAkB;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uCAAuC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+BAA+B;IAC/B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,gCAAgC;IAChC,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;CACvC;AAsHD,QAAA,MAAM,aAAa,kDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
@@ -1,6 +1,5 @@
1
1
  <script lang="ts">
2
2
  import { useI18n } from '@happyvertical/smrt-ui/i18n';
3
- import { onDestroy, onMount } from 'svelte';
4
3
  import { useAppState } from '../../hooks/useAppState.svelte.js';
5
4
  import { useSTT } from '../../hooks/useSTT.svelte.js';
6
5
  import { M } from '../../i18n/strings.forms.js';
@@ -9,6 +8,7 @@ import {
9
8
  type FieldDefinition,
10
9
  tryGetFormContext,
11
10
  } from '../../state/form-context.js';
11
+ import { invalidStagedValue } from './prepare-field-value.js';
12
12
  import type { DateRangeValue } from './types.js';
13
13
 
14
14
  const { t } = useI18n();
@@ -61,6 +61,8 @@ let {
61
61
  const app = useAppState();
62
62
  const stt = useSTT();
63
63
  const formContext = tryGetFormContext();
64
+ const fieldOwnerId = $props.id();
65
+ const fieldOwnerToken = `smrt-rich-field-${fieldOwnerId}`;
64
66
 
65
67
  const isSmrt = $derived(app.state.mode === 'smrt');
66
68
 
@@ -68,6 +70,7 @@ let isRecording = $state(false);
68
70
  let isParsing = $state(false);
69
71
  let parseError = $state<string | null>(null);
70
72
  let recordingStartTime = 0;
73
+ let primaryControl = $state<HTMLElement | null>(null);
71
74
 
72
75
  const MIN_HOLD_TIME = 500;
73
76
  const MIN_TRANSCRIPT_LENGTH = 2;
@@ -176,16 +179,37 @@ function formatToISO(date: Date): string {
176
179
  return `${year}-${month}-${day}`;
177
180
  }
178
181
 
182
+ function isCanonicalCalendarDate(value: string): boolean {
183
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
184
+ if (!match) return false;
185
+ const year = Number(match[1]);
186
+ const month = Number(match[2]);
187
+ const day = Number(match[3]);
188
+ const date = new Date(Date.UTC(year, month - 1, day));
189
+ return (
190
+ date.getUTCFullYear() === year &&
191
+ date.getUTCMonth() === month - 1 &&
192
+ date.getUTCDate() === day
193
+ );
194
+ }
195
+
179
196
  // Register with form context
180
- onMount(() => {
197
+ $effect(() => {
181
198
  if (formContext) {
199
+ const registeredName = name;
182
200
  const fieldDef: FieldDefinition = {
183
- name,
201
+ name: registeredName,
202
+ ownerToken: fieldOwnerToken,
184
203
  type: 'daterange',
185
- label,
186
- description:
187
- description ||
188
- 'A date range with start and end dates (say "from [start] to [end]")',
204
+ get label() {
205
+ return label;
206
+ },
207
+ get description() {
208
+ return (
209
+ description ||
210
+ 'A date range with start and end dates (say "from [start] to [end]")'
211
+ );
212
+ },
189
213
  setValue: (v: unknown) => {
190
214
  if (v === null || v === undefined) {
191
215
  updateValue('', '');
@@ -206,17 +230,102 @@ onMount(() => {
206
230
  .catch(() => {});
207
231
  },
208
232
  getValue: () => ({ startDate, endDate }),
209
- constraints: { required },
233
+ prepareValue: (candidate) => {
234
+ if (candidate === null || candidate === undefined) {
235
+ return { startDate: '', endDate: '' };
236
+ }
237
+ if (
238
+ typeof candidate !== 'object' ||
239
+ Array.isArray(candidate) ||
240
+ ![Object.prototype, null].includes(Object.getPrototypeOf(candidate))
241
+ ) {
242
+ return invalidStagedValue();
243
+ }
244
+ return candidate;
245
+ },
246
+ prepareExtractedValue: async (candidate) => {
247
+ if (typeof candidate !== 'string') {
248
+ return invalidStagedValue();
249
+ }
250
+ const parsed = await parseNaturalLanguageRange(candidate);
251
+ if (!parsed) {
252
+ return invalidStagedValue();
253
+ }
254
+ return { startDate: parsed.start, endDate: parsed.end };
255
+ },
256
+ clear: () => {
257
+ updateValue('', '');
258
+ return true;
259
+ },
260
+ getState: () => ({
261
+ disabled: disabled || primaryControl?.matches(':disabled') === true,
262
+ }),
263
+ get constraints() {
264
+ return { required };
265
+ },
266
+ focus: () => primaryControl?.focus(),
267
+ get webMcpSchema() {
268
+ return {
269
+ type: 'object',
270
+ additionalProperties: false,
271
+ properties: {
272
+ startDate: {
273
+ type: 'string',
274
+ format: 'date',
275
+ ...(minDate ? { formatMinimum: minDate } : {}),
276
+ ...(maxDate ? { formatMaximum: maxDate } : {}),
277
+ },
278
+ endDate: {
279
+ type: 'string',
280
+ format: 'date',
281
+ ...(minDate ? { formatMinimum: minDate } : {}),
282
+ ...(maxDate ? { formatMaximum: maxDate } : {}),
283
+ },
284
+ },
285
+ ...(required ? { required: ['startDate', 'endDate'] } : {}),
286
+ };
287
+ },
288
+ validateValue: (candidate) => {
289
+ if (candidate === null || candidate === undefined) return !required;
290
+ if (
291
+ typeof candidate !== 'object' ||
292
+ Array.isArray(candidate) ||
293
+ ![Object.prototype, null].includes(Object.getPrototypeOf(candidate))
294
+ ) {
295
+ return false;
296
+ }
297
+ const range = candidate as Record<string, unknown>;
298
+ if (
299
+ !Object.keys(range).every((key) =>
300
+ ['startDate', 'endDate'].includes(key),
301
+ ) ||
302
+ !Object.hasOwn(range, 'startDate') ||
303
+ !Object.hasOwn(range, 'endDate') ||
304
+ typeof range.startDate !== 'string' ||
305
+ typeof range.endDate !== 'string'
306
+ ) {
307
+ return false;
308
+ }
309
+ const start = range.startDate;
310
+ const end = range.endDate;
311
+ if (required && (!start || !end)) return false;
312
+ if (
313
+ (start && !isCanonicalCalendarDate(start)) ||
314
+ (end && !isCanonicalCalendarDate(end)) ||
315
+ (start && minDate && start < minDate) ||
316
+ (start && maxDate && start > maxDate) ||
317
+ (end && minDate && end < minDate) ||
318
+ (end && maxDate && end > maxDate) ||
319
+ (start && end && start > end)
320
+ ) {
321
+ return false;
322
+ }
323
+ return true;
324
+ },
210
325
  validate: () =>
211
326
  !required || (startDate.trim().length > 0 && endDate.trim().length > 0),
212
327
  };
213
- formContext.registerField(fieldDef);
214
- }
215
- });
216
-
217
- onDestroy(() => {
218
- if (formContext) {
219
- formContext.unregisterField(name);
328
+ return formContext.registerField(fieldDef);
220
329
  }
221
330
  });
222
331
 
@@ -336,7 +445,12 @@ function handleMicKeydown(e: KeyboardEvent) {
336
445
  const primaryControlId = $derived(isSmrt ? `${name}_voice` : `${name}_start`);
337
446
  </script>
338
447
 
339
- <div class="smrt-daterange" class:listening={isRecording} class:parsing={isParsing}>
448
+ <div
449
+ class="smrt-daterange"
450
+ class:listening={isRecording}
451
+ class:parsing={isParsing}
452
+ data-smrt-field-owner={fieldOwnerToken}
453
+ >
340
454
  {#if label}
341
455
  <label class="smrt-label" for={primaryControlId}>
342
456
  {label}
@@ -363,6 +477,7 @@ const primaryControlId = $derived(isSmrt ? `${name}_voice` : `${name}_start`);
363
477
  </div>
364
478
 
365
479
  <button
480
+ bind:this={primaryControl}
366
481
  id={primaryControlId}
367
482
  type="button"
368
483
  class="mic-btn"
@@ -395,6 +510,7 @@ const primaryControlId = $derived(isSmrt ? `${name}_voice` : `${name}_start`);
395
510
  <div class="date-field">
396
511
  <label for="{name}_start" class="field-label">Start</label>
397
512
  <input
513
+ bind:this={primaryControl}
398
514
  id="{name}_start"
399
515
  name="{name}[startDate]"
400
516
  type="date"
@@ -1 +1 @@
1
- {"version":3,"file":"DateRangeInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/DateRangeInput.svelte.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAGjD,MAAM,WAAW,KAAK;IACpB,iBAAiB;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,kBAAkB;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uCAAuC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6BAA6B;IAC7B,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,2BAA2B;IAC3B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,2DAA2D;IAC3D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yDAAyD;IACzD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,8BAA8B;IAC9B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,8BAA8B;IAC9B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,+BAA+B;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gCAAgC;IAChC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;CAC5C;AAqYD,QAAA,MAAM,cAAc,gEAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
1
+ {"version":3,"file":"DateRangeInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/DateRangeInput.svelte.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAGjD,MAAM,WAAW,KAAK;IACpB,iBAAiB;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,kBAAkB;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uCAAuC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6BAA6B;IAC7B,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,2BAA2B;IAC3B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,2DAA2D;IAC3D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yDAAyD;IACzD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,8BAA8B;IAC9B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,8BAA8B;IAC9B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,+BAA+B;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gCAAgC;IAChC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;CAC5C;AAkfD,QAAA,MAAM,cAAc,gEAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}