@happyvertical/smrt-svelte 0.51.39 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/AGENTS.md +3 -0
  2. package/README.md +167 -1
  3. package/agents/workspace.md +24 -0
  4. package/dist/browser-ai/adapters/stt/browser-speech.d.ts +11 -0
  5. package/dist/browser-ai/adapters/stt/browser-speech.d.ts.map +1 -1
  6. package/dist/browser-ai/adapters/stt/browser-speech.js +20 -4
  7. package/dist/browser-ai/adapters/stt/browser-speech.test.js +113 -0
  8. package/dist/browser-ai/adapters/stt/dictation-source.d.ts +3 -0
  9. package/dist/browser-ai/adapters/stt/dictation-source.d.ts.map +1 -0
  10. package/dist/browser-ai/adapters/stt/dictation-source.js +38 -0
  11. package/dist/browser-ai/adapters/stt/dictation-source.test.js +52 -0
  12. package/dist/browser-ai/adapters/stt/index.d.ts +2 -1
  13. package/dist/browser-ai/adapters/stt/index.d.ts.map +1 -1
  14. package/dist/browser-ai/adapters/stt/index.js +2 -1
  15. package/dist/components/forms/Form.svelte +118 -8
  16. package/dist/components/forms/Form.svelte.d.ts +18 -2
  17. package/dist/components/forms/Form.svelte.d.ts.map +1 -1
  18. package/dist/components/forms/FormScope.svelte +202 -0
  19. package/dist/components/forms/FormScope.svelte.d.ts +22 -0
  20. package/dist/components/forms/FormScope.svelte.d.ts.map +1 -0
  21. package/dist/components/forms/__tests__/Form.ui-controls.test.js +119 -0
  22. package/dist/components/forms/__tests__/Form.webmcp.test.js +5 -3
  23. package/dist/components/forms/__tests__/FormScope.test.js +39 -0
  24. package/dist/components/forms/__tests__/form-scope.fixture.svelte +30 -0
  25. package/dist/components/forms/__tests__/form-scope.fixture.svelte.d.ts +10 -0
  26. package/dist/components/forms/__tests__/form-scope.fixture.svelte.d.ts.map +1 -0
  27. package/dist/components/forms/__tests__/form-with-ui-controls.fixture.svelte +84 -0
  28. package/dist/components/forms/__tests__/form-with-ui-controls.fixture.svelte.d.ts +14 -0
  29. package/dist/components/forms/__tests__/form-with-ui-controls.fixture.svelte.d.ts.map +1 -0
  30. package/dist/components/forms/index.d.ts +1 -0
  31. package/dist/components/forms/index.d.ts.map +1 -1
  32. package/dist/components/forms/index.js +1 -0
  33. package/dist/components/workspace/README.md +11 -0
  34. package/dist/components/workspace/__tests__/AdminShell.responsive.test.js +586 -0
  35. package/dist/components/workspace/__tests__/AdminShell.test.js +85 -0
  36. package/dist/components/workspace/__tests__/TenantNav.test.js +49 -0
  37. package/dist/components/workspace/__tests__/admin-shell-header-overflow.test.js +25 -0
  38. package/dist/components/workspace/__tests__/admin-shell-mobile.test.js +217 -0
  39. package/dist/components/workspace/__tests__/admin-shell-page-contracts.test.js +30 -0
  40. package/dist/components/workspace/__tests__/admin-shell-responsive-state.test.js +184 -0
  41. package/dist/components/workspace/__tests__/admin-shell-theme-hooks.test.js +36 -0
  42. package/dist/components/workspace/__tests__/shell-chrome-components.test.js +108 -0
  43. package/dist/components/workspace/admin-shell/AdminShell.svelte +998 -40
  44. package/dist/components/workspace/admin-shell/AdminShell.svelte.d.ts +42 -1
  45. package/dist/components/workspace/admin-shell/AdminShell.svelte.d.ts.map +1 -1
  46. package/dist/components/workspace/admin-shell/PhoneBottomBar.svelte +170 -0
  47. package/dist/components/workspace/admin-shell/PhoneBottomBar.svelte.d.ts +11 -0
  48. package/dist/components/workspace/admin-shell/PhoneBottomBar.svelte.d.ts.map +1 -0
  49. package/dist/components/workspace/admin-shell/PhoneTopBar.svelte +93 -0
  50. package/dist/components/workspace/admin-shell/PhoneTopBar.svelte.d.ts +11 -0
  51. package/dist/components/workspace/admin-shell/PhoneTopBar.svelte.d.ts.map +1 -0
  52. package/dist/components/workspace/admin-shell/ShellNavToggle.svelte +88 -0
  53. package/dist/components/workspace/admin-shell/ShellNavToggle.svelte.d.ts +14 -0
  54. package/dist/components/workspace/admin-shell/ShellNavToggle.svelte.d.ts.map +1 -0
  55. package/dist/components/workspace/admin-shell/ShellTitle.svelte +42 -0
  56. package/dist/components/workspace/admin-shell/ShellTitle.svelte.d.ts +11 -0
  57. package/dist/components/workspace/admin-shell/ShellTitle.svelte.d.ts.map +1 -0
  58. package/dist/components/workspace/admin-shell/TenantNav.svelte +46 -1
  59. package/dist/components/workspace/admin-shell/TenantNav.svelte.d.ts.map +1 -1
  60. package/dist/components/workspace/admin-shell/mobile-shell-dom.d.ts +37 -0
  61. package/dist/components/workspace/admin-shell/mobile-shell-dom.d.ts.map +1 -0
  62. package/dist/components/workspace/admin-shell/mobile-shell-dom.js +150 -0
  63. package/dist/components/workspace/admin-shell/mobile-shell.d.ts +191 -0
  64. package/dist/components/workspace/admin-shell/mobile-shell.d.ts.map +1 -0
  65. package/dist/components/workspace/admin-shell/mobile-shell.js +165 -0
  66. package/dist/components/workspace/admin-shell/settings.d.ts +27 -2
  67. package/dist/components/workspace/admin-shell/settings.d.ts.map +1 -1
  68. package/dist/components/workspace/admin-shell/settings.js +84 -2
  69. package/dist/components/workspace/admin-shell/state.svelte.d.ts +55 -1
  70. package/dist/components/workspace/admin-shell/state.svelte.d.ts.map +1 -1
  71. package/dist/components/workspace/admin-shell/state.svelte.js +149 -6
  72. package/dist/components/workspace/admin-shell/types.d.ts +97 -0
  73. package/dist/components/workspace/admin-shell/types.d.ts.map +1 -1
  74. package/dist/components/workspace/admin-shell/types.js +13 -0
  75. package/dist/components/workspace/index.d.ts +11 -4
  76. package/dist/components/workspace/index.d.ts.map +1 -1
  77. package/dist/components/workspace/index.js +12 -3
  78. package/dist/i18n/strings.workspace.d.ts +10 -0
  79. package/dist/i18n/strings.workspace.d.ts.map +1 -1
  80. package/dist/i18n/strings.workspace.js +10 -0
  81. package/dist/index.d.ts +1 -1
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +1 -1
  84. package/dist/web/__tests__/assistant-dock.integration.svelte.test.js +2 -2
  85. package/dist/web/__tests__/list-sort-surface.fixture.svelte +41 -0
  86. package/dist/web/__tests__/list-sort-surface.fixture.svelte.d.ts +15 -0
  87. package/dist/web/__tests__/list-sort-surface.fixture.svelte.d.ts.map +1 -0
  88. package/dist/web/__tests__/list-sort-surface.svelte.test.js +59 -0
  89. package/dist/web/__tests__/surfaces.child.fixture.svelte +69 -0
  90. package/dist/web/__tests__/surfaces.child.fixture.svelte.d.ts +16 -0
  91. package/dist/web/__tests__/surfaces.child.fixture.svelte.d.ts.map +1 -0
  92. package/dist/web/__tests__/surfaces.fixture.svelte +26 -0
  93. package/dist/web/__tests__/surfaces.fixture.svelte.d.ts +18 -0
  94. package/dist/web/__tests__/surfaces.fixture.svelte.d.ts.map +1 -0
  95. package/dist/web/__tests__/surfaces.svelte.test.js +140 -0
  96. package/dist/web/__tests__/webmcp-ui.test.js +18 -0
  97. package/dist/web/index.d.ts +4 -1
  98. package/dist/web/index.d.ts.map +1 -1
  99. package/dist/web/index.js +4 -1
  100. package/dist/web/link-surface.svelte.d.ts +14 -0
  101. package/dist/web/link-surface.svelte.d.ts.map +1 -0
  102. package/dist/web/link-surface.svelte.js +49 -0
  103. package/dist/web/list-surface.svelte.d.ts +80 -0
  104. package/dist/web/list-surface.svelte.d.ts.map +1 -0
  105. package/dist/web/list-surface.svelte.js +309 -0
  106. package/dist/web/step-surface.svelte.d.ts +17 -0
  107. package/dist/web/step-surface.svelte.d.ts.map +1 -0
  108. package/dist/web/step-surface.svelte.js +56 -0
  109. package/dist/web/webmcp-ui-context.d.ts +7 -0
  110. package/dist/web/webmcp-ui-context.d.ts.map +1 -1
  111. package/dist/web/webmcp-ui-context.js +10 -0
  112. package/dist/web/webmcp-ui.d.ts.map +1 -1
  113. package/dist/web/webmcp-ui.js +16 -3
  114. package/package.json +16 -16
@@ -6,13 +6,16 @@ import {
6
6
  type ControlKind,
7
7
  type ControlRuntimeState,
8
8
  type ControlSubject,
9
+ controlProposalProperties,
9
10
  createControlInteractionRegistry,
10
11
  StagedControlReview,
11
12
  setControlInteractionContext,
13
+ stageControlProposals,
12
14
  } from '@happyvertical/smrt-ui/forms';
13
15
  import { useI18n } from '@happyvertical/smrt-ui/i18n';
14
16
  import type { Snippet } from 'svelte';
15
17
  import { onDestroy, untrack } from 'svelte';
18
+ import type { HTMLFormAttributes } from 'svelte/elements';
16
19
  import { useAppState } from '../../hooks/useAppState.svelte.js';
17
20
  import { useSTT } from '../../hooks/useSTT.svelte.js';
18
21
  import { M } from '../../i18n/strings.forms.js';
@@ -28,7 +31,20 @@ import type { LLMModelId, STTAdapterType } from './types.js';
28
31
 
29
32
  const { t } = useI18n();
30
33
 
31
- export interface Props {
34
+ /**
35
+ * A SvelteKit-style form action: `enhance` from `$app/forms` fits as-is, or
36
+ * wrap it to pass a submit function (`(form) => enhance(form, submit)`).
37
+ */
38
+ export type FormEnhance = (
39
+ form: HTMLFormElement,
40
+ // biome-ignore lint/suspicious/noConfusingVoidType: an action may return nothing, like Svelte's own Action type.
41
+ ) => void | { destroy?: () => void };
42
+
43
+ export interface Props
44
+ extends Omit<
45
+ HTMLFormAttributes,
46
+ 'children' | 'class' | 'id' | 'name' | 'method' | 'action' | 'onsubmit'
47
+ > {
32
48
  /** Form children */
33
49
  children: Snippet;
34
50
  /** Show mode toggle button */
@@ -48,7 +64,7 @@ export interface Props {
48
64
  /** Collection/model identity used when naming the generated intent. */
49
65
  collection?: string;
50
66
  /** HTTP method for native form submission (default: GET) */
51
- method?: 'GET' | 'POST';
67
+ method?: 'GET' | 'POST' | 'get' | 'post' | 'dialog';
52
68
  /** Form action URL for native form submission */
53
69
  action?: string;
54
70
  /** Stable identity used by control/agent interaction adapters. */
@@ -67,6 +83,14 @@ export interface Props {
67
83
  name?: string;
68
84
  /** CSS classes to apply to the form element. */
69
85
  class?: string;
86
+ /**
87
+ * Progressive-enhancement action applied to the rendered `<form>` — pass
88
+ * SvelteKit's `enhance` (`<Form enhance={enhance}>`). A component cannot
89
+ * take `use:`, so this is the `use:enhance` equivalent; attachments
90
+ * (`{@attach …}`) and every other native form attribute (`enctype`,
91
+ * `novalidate`, `aria-*`, `data-*`, `autocomplete`, …) pass straight through.
92
+ */
93
+ enhance?: FormEnhance;
70
94
  }
71
95
 
72
96
  const {
@@ -89,8 +113,27 @@ const {
89
113
  id,
90
114
  name,
91
115
  class: className = '',
116
+ enhance,
117
+ ...formAttributes
92
118
  }: Props = $props();
93
119
 
120
+ /** Apply the consumer's `enhance` action; re-run it when the action changes. */
121
+ function applyEnhance(form: HTMLFormElement, initial: FormEnhance | undefined) {
122
+ let current = initial;
123
+ let handle = current?.(form);
124
+ return {
125
+ update(next: FormEnhance | undefined) {
126
+ if (next === current) return;
127
+ if (handle) handle.destroy?.();
128
+ current = next;
129
+ handle = current?.(form);
130
+ },
131
+ destroy() {
132
+ if (handle) handle.destroy?.();
133
+ },
134
+ };
135
+ }
136
+
94
137
  const app = useAppState();
95
138
  const stt = useSTT();
96
139
  const instanceId = $props.id();
@@ -291,7 +334,25 @@ function recordDirectUserEdit(event: Event) {
291
334
  target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement,
292
335
  ),
293
336
  );
294
- if (!field) return;
337
+ if (!field) {
338
+ // A smrt-ui primitive (Input, Select, Combobox, …) registered itself
339
+ // through the control context; its element carries the address.
340
+ const control = target.closest<HTMLElement>('[data-smrt-control]');
341
+ const controlId = control?.dataset.smrtControl;
342
+ if (!control || !controlId) return;
343
+ resolvedInteractionRegistry.recordUserEdit?.({
344
+ formId: resolvedFormId,
345
+ controlId,
346
+ subject:
347
+ control.dataset.smrtSubjectType && control.dataset.smrtSubjectId
348
+ ? {
349
+ type: control.dataset.smrtSubjectType,
350
+ id: control.dataset.smrtSubjectId,
351
+ }
352
+ : undefined,
353
+ });
354
+ return;
355
+ }
295
356
  resolvedInteractionRegistry.recordUserEdit?.({
296
357
  formId: resolvedFormId,
297
358
  controlId: field.controlId ?? field.name,
@@ -812,6 +873,16 @@ function prepareInteractionValue(field: FieldDefinition, value: unknown) {
812
873
  return field.prepareValue ? field.prepareValue(preparedValue) : preparedValue;
813
874
  }
814
875
 
876
+ /** Registry control ids owned by fields registered through this Form. */
877
+ function richFieldControlIds(): Set<string> {
878
+ const ids = new Set<string>();
879
+ for (const field of fields.values()) {
880
+ ids.add(field.controlId ?? field.name);
881
+ ids.add(field.name);
882
+ }
883
+ return ids;
884
+ }
885
+
815
886
  function formInputSchema(): Record<string, unknown> {
816
887
  void interactionRegistryRevision;
817
888
  const properties: Record<string, unknown> = {};
@@ -830,6 +901,17 @@ function formInputSchema(): Record<string, unknown> {
830
901
  if (field.constraints?.required) required.push(field.name);
831
902
  }
832
903
 
904
+ // Every other control mounted in this form — smrt-ui primitives
905
+ // (FormGroup + Input/Select/Textarea/Combobox/…) and composites registered
906
+ // through useControlRegistration — proposes through the same tool.
907
+ for (const [controlId, schema] of Object.entries(
908
+ controlProposalProperties(resolvedInteractionRegistry, resolvedFormId, {
909
+ exclude: richFieldControlIds(),
910
+ }),
911
+ )) {
912
+ if (!Object.hasOwn(properties, controlId)) properties[controlId] = schema;
913
+ }
914
+
833
915
  return {
834
916
  type: 'object',
835
917
  additionalProperties: false,
@@ -879,7 +961,23 @@ async function submitCurrentForm(): Promise<string> {
879
961
  }
880
962
 
881
963
  async function stageForWebMcp(args: Record<string, unknown>): Promise<string> {
882
- const commands = Object.entries(args).flatMap(([name, value]) => {
964
+ const richArgs: Record<string, unknown> = {};
965
+ const controlArgs: Record<string, unknown> = {};
966
+ for (const [key, value] of Object.entries(args ?? {})) {
967
+ if (fields.has(key)) richArgs[key] = value;
968
+ else controlArgs[key] = value;
969
+ }
970
+ const controlResult =
971
+ Object.keys(controlArgs).length > 0
972
+ ? await stageControlProposals(
973
+ resolvedInteractionRegistry,
974
+ resolvedFormId,
975
+ controlArgs,
976
+ { source: 'agent', actorId: 'webmcp' },
977
+ { exclude: richFieldControlIds() },
978
+ )
979
+ : undefined;
980
+ const commands = Object.entries(richArgs).flatMap(([name, value]) => {
883
981
  const field = fields.get(name);
884
982
  const state = field ? fieldRuntimeState(field) : undefined;
885
983
  if (
@@ -914,11 +1012,17 @@ async function stageForWebMcp(args: Record<string, unknown>): Promise<string> {
914
1012
  },
915
1013
  ];
916
1014
  });
917
- if (commands.length === 0) return 'No reviewable changes provided';
1015
+ const controlStaged = controlResult?.staged ?? 0;
1016
+ const controlRejected = controlResult?.rejected.length ?? 0;
1017
+ if (commands.length === 0 && controlStaged + controlRejected === 0) {
1018
+ return 'No reviewable changes provided';
1019
+ }
918
1020
 
919
1021
  const context = { source: 'agent' as const, actorId: 'webmcp' };
920
1022
  let batch;
921
- if (resolvedInteractionRegistry.executeBatch) {
1023
+ if (commands.length === 0) {
1024
+ batch = { ok: true, results: [] };
1025
+ } else if (resolvedInteractionRegistry.executeBatch) {
922
1026
  batch = await resolvedInteractionRegistry.executeBatch(commands, context);
923
1027
  } else {
924
1028
  const results = [];
@@ -927,8 +1031,12 @@ async function stageForWebMcp(args: Record<string, unknown>): Promise<string> {
927
1031
  }
928
1032
  batch = { ok: results.every((result) => result.ok), results };
929
1033
  }
930
- const completed = batch.results.filter((result) => result.ok).length;
931
- const rejected = batch.results.length - completed;
1034
+ const completed =
1035
+ batch.results.filter((result) => result.ok).length + controlStaged;
1036
+ const rejected =
1037
+ batch.results.length -
1038
+ batch.results.filter((result) => result.ok).length +
1039
+ controlRejected;
932
1040
  return rejected === 0
933
1041
  ? `Staged ${completed} change${completed === 1 ? '' : 's'} for review`
934
1042
  : `Staged ${completed} changes for review; ${rejected} rejected`;
@@ -1412,6 +1520,7 @@ export function getInteractionRegistry(): ControlInteractionRegistry {
1412
1520
  </script>
1413
1521
 
1414
1522
  <form
1523
+ {...formAttributes}
1415
1524
  bind:this={formElement}
1416
1525
  {id}
1417
1526
  {name}
@@ -1420,6 +1529,7 @@ export function getInteractionRegistry(): ControlInteractionRegistry {
1420
1529
  onsubmit={handleSubmit}
1421
1530
  {method}
1422
1531
  {action}
1532
+ use:applyEnhance={enhance}
1423
1533
  >
1424
1534
  <!--
1425
1535
  Screen-reader status region (L1 #1420): announces async STT / field-
@@ -1,7 +1,15 @@
1
1
  import { type ControlInteractionEvent, type ControlInteractionRegistry, type ControlSubject } from '@happyvertical/smrt-ui/forms';
2
2
  import type { Snippet } from 'svelte';
3
+ import type { HTMLFormAttributes } from 'svelte/elements';
3
4
  import type { LLMModelId, STTAdapterType } from './types.js';
4
- export interface Props {
5
+ /**
6
+ * A SvelteKit-style form action: `enhance` from `$app/forms` fits as-is, or
7
+ * wrap it to pass a submit function (`(form) => enhance(form, submit)`).
8
+ */
9
+ export type FormEnhance = (form: HTMLFormElement) => void | {
10
+ destroy?: () => void;
11
+ };
12
+ export interface Props extends Omit<HTMLFormAttributes, 'children' | 'class' | 'id' | 'name' | 'method' | 'action' | 'onsubmit'> {
5
13
  /** Form children */
6
14
  children: Snippet;
7
15
  /** Show mode toggle button */
@@ -24,7 +32,7 @@ export interface Props {
24
32
  /** Collection/model identity used when naming the generated intent. */
25
33
  collection?: string;
26
34
  /** HTTP method for native form submission (default: GET) */
27
- method?: 'GET' | 'POST';
35
+ method?: 'GET' | 'POST' | 'get' | 'post' | 'dialog';
28
36
  /** Form action URL for native form submission */
29
37
  action?: string;
30
38
  /** Stable identity used by control/agent interaction adapters. */
@@ -43,6 +51,14 @@ export interface Props {
43
51
  name?: string;
44
52
  /** CSS classes to apply to the form element. */
45
53
  class?: string;
54
+ /**
55
+ * Progressive-enhancement action applied to the rendered `<form>` — pass
56
+ * SvelteKit's `enhance` (`<Form enhance={enhance}>`). A component cannot
57
+ * take `use:`, so this is the `use:enhance` equivalent; attachments
58
+ * (`{@attach …}`) and every other native form attribute (`enctype`,
59
+ * `novalidate`, `aria-*`, `data-*`, `autocomplete`, …) pass straight through.
60
+ */
61
+ enhance?: FormEnhance;
46
62
  }
47
63
  declare const Form: import("svelte").Component<Props, {
48
64
  getFormData: () => Record<string, unknown>;
@@ -1 +1 @@
1
- {"version":3,"file":"Form.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/Form.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,EAEL,KAAK,uBAAuB,EAC5B,KAAK,0BAA0B,EAG/B,KAAK,cAAc,EAIpB,MAAM,8BAA8B,CAAC;AAEtC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAatC,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAG7D,MAAM,WAAW,KAAK;IACpB,oBAAoB;IACpB,QAAQ,EAAE,OAAO,CAAC;IAClB,8BAA8B;IAC9B,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,oCAAoC;IACpC,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,iDAAiD;IACjD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,uBAAuB;IACvB,UAAU,CAAC,EAAE,cAAc,CAAC;IAC5B,0DAA0D;IAC1D,QAAQ,CAAC,EAAE,UAAU,CAAC;IACtB,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnE,+DAA+D;IAC/D,MAAM,CAAC,EAAE,OAAO,GAAG;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC3D,uEAAuE;IACvE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,4DAA4D;IAC5D,MAAM,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IACxB,iDAAiD;IACjD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,kEAAkE;IAClE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,+DAA+D;IAC/D,OAAO,CAAC,EAAE,cAAc,CAAC;IACzB,+DAA+D;IAC/D,mBAAmB,CAAC,EAAE,0BAA0B,CAAC;IACjD,2DAA2D;IAC3D,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,uBAAuB,KAAK,IAAI,CAAC;IACzD,mEAAmE;IACnE,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,8CAA8C;IAC9C,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,gDAAgD;IAChD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,gDAAgD;IAChD,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAq5CD,QAAA,MAAM,IAAI;uBAjFe,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;kCAQZ,0BAA0B;MAyEZ,CAAC;AACnD,KAAK,IAAI,GAAG,UAAU,CAAC,OAAO,IAAI,CAAC,CAAC;AACpC,eAAe,IAAI,CAAC"}
1
+ {"version":3,"file":"Form.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/Form.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,EAEL,KAAK,uBAAuB,EAC5B,KAAK,0BAA0B,EAG/B,KAAK,cAAc,EAMpB,MAAM,8BAA8B,CAAC;AAEtC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAEtC,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AAY1D,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAG7D;;;GAGG;AACH,MAAM,MAAM,WAAW,GAAG,CACxB,IAAI,EAAE,eAAe,KAElB,IAAI,GAAG;IAAE,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;CAAE,CAAC;AAErC,MAAM,WAAW,KACf,SAAQ,IAAI,CACV,kBAAkB,EAClB,UAAU,GAAG,OAAO,GAAG,IAAI,GAAG,MAAM,GAAG,QAAQ,GAAG,QAAQ,GAAG,UAAU,CACxE;IACD,oBAAoB;IACpB,QAAQ,EAAE,OAAO,CAAC;IAClB,8BAA8B;IAC9B,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,oCAAoC;IACpC,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,iDAAiD;IACjD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,uBAAuB;IACvB,UAAU,CAAC,EAAE,cAAc,CAAC;IAC5B,0DAA0D;IAC1D,QAAQ,CAAC,EAAE,UAAU,CAAC;IACtB,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnE,+DAA+D;IAC/D,MAAM,CAAC,EAAE,OAAO,GAAG;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC3D,uEAAuE;IACvE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,4DAA4D;IAC5D,MAAM,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,MAAM,GAAG,QAAQ,CAAC;IACpD,iDAAiD;IACjD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,kEAAkE;IAClE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,+DAA+D;IAC/D,OAAO,CAAC,EAAE,cAAc,CAAC;IACzB,+DAA+D;IAC/D,mBAAmB,CAAC,EAAE,0BAA0B,CAAC;IACjD,2DAA2D;IAC3D,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,uBAAuB,KAAK,IAAI,CAAC;IACzD,mEAAmE;IACnE,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,8CAA8C;IAC9C,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,gDAAgD;IAChD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,gDAAgD;IAChD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,WAAW,CAAC;CACvB;AA2+CD,QAAA,MAAM,IAAI;uBAjFe,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;kCAQZ,0BAA0B;MAyEZ,CAAC;AACnD,KAAK,IAAI,GAAG,UAAU,CAAC,OAAO,IAAI,CAAC,CAAC;AACpC,eAAe,IAAI,CAAC"}
@@ -0,0 +1,202 @@
1
+ <!--
2
+ FormScope — makes the fields inside it readable and proposable by agents
3
+ without rendering a `<form>`.
4
+
5
+ Use the rich `<Form webmcp>` when the page has a form: it now passes every
6
+ native attribute and SvelteKit's `enhance` through. FormScope is for the
7
+ cases a `<form>` element does not fit — a fetch-driven wizard whose buttons
8
+ are `type="button"`, an editor made of several independently saved parts,
9
+ or a page that must keep its own `<form>` markup (scoped `form.x` styles).
10
+
11
+ <FormScope formId="new-site" purpose="Start a new town site">
12
+ <FormGroup label="Site name" id="site-name"><Input name="name" bind:value={name} /></FormGroup>
13
+ </FormScope>
14
+
15
+ Inside, smrt-ui primitives (FormGroup + Input/Select/Textarea/Combobox/…)
16
+ and composites registered through `useControlRegistration` join the nearest
17
+ `<Provider webmcp>` control registry (or a local one), and — with `webmcp`
18
+ (default on) and at least one proposable control — one
19
+ `<formId>_stage_changes` tool lets an agent PROPOSE values. Proposals show
20
+ in the staged-review panel; a person applies them. Nothing is saved or
21
+ submitted by an agent.
22
+ -->
23
+ <script lang="ts">
24
+ import {
25
+ type ControlInteractionRegistry,
26
+ controlProposalInputSchema,
27
+ controlProposalToolName,
28
+ createControlInteractionRegistry,
29
+ StagedControlReview,
30
+ setControlInteractionContext,
31
+ stageControlProposals,
32
+ } from '@happyvertical/smrt-ui/forms';
33
+ import type { Snippet } from 'svelte';
34
+ import { untrack } from 'svelte';
35
+ import { useWebMcpTool } from '../../web/webmcp.svelte.js';
36
+ import { tryGetWebMcpUiContext } from '../../web/webmcp-ui-context.js';
37
+
38
+ export interface Props {
39
+ /** Stable, page-unique form identity (also names the WebMCP tool). */
40
+ formId: string;
41
+ /** Plain-language name of what the form does, for the tool description. */
42
+ purpose?: string;
43
+ /** Register the `<formId>_stage_changes` proposal tool (default true). */
44
+ webmcp?: boolean | { name?: string; description?: string };
45
+ /** Override the registry (tests, nested hosts). */
46
+ interactionRegistry?: ControlInteractionRegistry;
47
+ /** Show the staged-review summary for agent proposals (default true). */
48
+ stagedReview?: boolean;
49
+ children: Snippet;
50
+ }
51
+
52
+ const {
53
+ formId,
54
+ purpose,
55
+ webmcp = true,
56
+ interactionRegistry,
57
+ stagedReview = true,
58
+ children,
59
+ }: Props = $props();
60
+
61
+ const providerUi = tryGetWebMcpUiContext();
62
+ const localRegistry = createControlInteractionRegistry();
63
+ const registry = $derived(
64
+ interactionRegistry ??
65
+ (providerUi?.enabled ? providerUi.controlRegistry : undefined) ??
66
+ localRegistry,
67
+ );
68
+
69
+ setControlInteractionContext({
70
+ get formId() {
71
+ return formId;
72
+ },
73
+ get registry() {
74
+ return registry;
75
+ },
76
+ });
77
+
78
+ let scope = $state<HTMLDivElement | null>(null);
79
+ let formElement = $state<HTMLFormElement | null>(null);
80
+ $effect(() => {
81
+ if (!scope) return;
82
+ const element = scope;
83
+ const find = () => {
84
+ formElement = element.querySelector('form');
85
+ };
86
+ find();
87
+ // A form can appear after mount ({#if} around it); keep the reference current.
88
+ const observer = new MutationObserver(find);
89
+ observer.observe(element, { childList: true, subtree: true });
90
+ return () => observer.disconnect();
91
+ });
92
+
93
+ // Record trusted human edits so a staged proposal goes stale when the person
94
+ // types over it (the same contract as the base and rich Form).
95
+ function recordDirectUserEdit(event: Event) {
96
+ if (!event.isTrusted) return;
97
+ const target = event.target;
98
+ if (!(target instanceof Element)) return;
99
+ const control = target.closest<HTMLElement>('[data-smrt-control]');
100
+ const controlId = control?.dataset.smrtControl;
101
+ if (!control || !controlId) return;
102
+ registry.recordUserEdit?.({
103
+ formId,
104
+ controlId,
105
+ subject:
106
+ control.dataset.smrtSubjectType && control.dataset.smrtSubjectId
107
+ ? {
108
+ type: control.dataset.smrtSubjectType,
109
+ id: control.dataset.smrtSubjectId,
110
+ }
111
+ : undefined,
112
+ });
113
+ }
114
+
115
+ $effect(() => {
116
+ const element = scope;
117
+ if (!element) return;
118
+ element.addEventListener('input', recordDirectUserEdit);
119
+ element.addEventListener('change', recordDirectUserEdit);
120
+ return () => {
121
+ element.removeEventListener('input', recordDirectUserEdit);
122
+ element.removeEventListener('change', recordDirectUserEdit);
123
+ };
124
+ });
125
+
126
+ // The registry is not reactive; bump a counter on registration changes so
127
+ // the tool's schema follows controls as they mount, unmount, or disable.
128
+ let registryRevision = $state(0);
129
+ $effect(() => {
130
+ const currentFormId = formId;
131
+ return registry.subscribe((event) => {
132
+ if (event.identity.formId !== currentFormId) return;
133
+ if (
134
+ event.type === 'registered' ||
135
+ event.type === 'unregistered' ||
136
+ event.type === 'refreshed'
137
+ ) {
138
+ registryRevision = untrack(() => registryRevision) + 1;
139
+ }
140
+ });
141
+ });
142
+
143
+ useWebMcpTool(
144
+ () => {
145
+ if (!webmcp) return null;
146
+ void registryRevision;
147
+ const options = typeof webmcp === 'object' ? webmcp : {};
148
+ const currentRegistry = registry;
149
+ const currentFormId = formId;
150
+ const inputSchema = controlProposalInputSchema(
151
+ currentRegistry,
152
+ currentFormId,
153
+ );
154
+ // Nothing an agent could fill (yet): no tool, rather than an empty one.
155
+ if (Object.keys(inputSchema.properties as object).length === 0) return null;
156
+ return {
157
+ name: options.name ?? controlProposalToolName(currentFormId),
158
+ description:
159
+ options.description ??
160
+ `Propose values for the "${purpose ?? currentFormId}" form. The person reviews and applies them; nothing is saved or submitted.`,
161
+ inputSchema,
162
+ // Stages proposals only: write-class, never destructive.
163
+ annotations: {
164
+ readOnlyHint: false,
165
+ destructiveHint: false,
166
+ idempotentHint: true,
167
+ openWorldHint: false,
168
+ },
169
+ execute: async (args) => {
170
+ const result = await stageControlProposals(
171
+ currentRegistry,
172
+ currentFormId,
173
+ (args ?? {}) as Record<string, unknown>,
174
+ { source: 'agent', actorId: 'assistant' },
175
+ );
176
+ return JSON.stringify({
177
+ ...result,
178
+ message:
179
+ result.staged > 0
180
+ ? 'Proposed changes are waiting for the person to review and apply them. Nothing was saved or submitted.'
181
+ : 'No reviewable changes provided',
182
+ });
183
+ },
184
+ };
185
+ },
186
+ // Same fallback as `<Form webmcp>`: used only when no Provider declares an
187
+ // explicit `webmcp.effects` policy.
188
+ { effects: ['read', 'write'] },
189
+ );
190
+ </script>
191
+
192
+ <div class="smrt-form-scope" data-smrt-form-scope={formId} bind:this={scope}>
193
+ {@render children()}
194
+ <StagedControlReview {registry} {formId} {formElement} summary={stagedReview} />
195
+ </div>
196
+
197
+ <style>
198
+ /* Layout-neutral: the wrapped content lays out exactly as before. */
199
+ .smrt-form-scope {
200
+ display: contents;
201
+ }
202
+ </style>
@@ -0,0 +1,22 @@
1
+ import { type ControlInteractionRegistry } from '@happyvertical/smrt-ui/forms';
2
+ import type { Snippet } from 'svelte';
3
+ export interface Props {
4
+ /** Stable, page-unique form identity (also names the WebMCP tool). */
5
+ formId: string;
6
+ /** Plain-language name of what the form does, for the tool description. */
7
+ purpose?: string;
8
+ /** Register the `<formId>_stage_changes` proposal tool (default true). */
9
+ webmcp?: boolean | {
10
+ name?: string;
11
+ description?: string;
12
+ };
13
+ /** Override the registry (tests, nested hosts). */
14
+ interactionRegistry?: ControlInteractionRegistry;
15
+ /** Show the staged-review summary for agent proposals (default true). */
16
+ stagedReview?: boolean;
17
+ children: Snippet;
18
+ }
19
+ declare const FormScope: import("svelte").Component<Props, {}, "">;
20
+ type FormScope = ReturnType<typeof FormScope>;
21
+ export default FormScope;
22
+ //# sourceMappingURL=FormScope.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FormScope.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/FormScope.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,KAAK,0BAA0B,EAOhC,MAAM,8BAA8B,CAAC;AACtC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAMtC,MAAM,WAAW,KAAK;IACpB,sEAAsE;IACtE,MAAM,EAAE,MAAM,CAAC;IACf,2EAA2E;IAC3E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,0EAA0E;IAC1E,MAAM,CAAC,EAAE,OAAO,GAAG;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC3D,mDAAmD;IACnD,mBAAmB,CAAC,EAAE,0BAA0B,CAAC;IACjD,yEAAyE;IACzE,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,QAAQ,EAAE,OAAO,CAAC;CACnB;AA+JD,QAAA,MAAM,SAAS,2CAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
@@ -0,0 +1,119 @@
1
+ import { createControlInteractionRegistry } from '@happyvertical/smrt-ui/forms';
2
+ import { render, screen, waitFor } from '@testing-library/svelte';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { tick } from 'svelte';
5
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6
+ vi.mock('../../../hooks/useAppState.svelte.js', () => ({
7
+ useAppState: () => ({ state: { mode: 'default' }, setMode: vi.fn() }),
8
+ }));
9
+ vi.mock('../../../hooks/useSTT.svelte.js', () => ({
10
+ useSTT: () => ({
11
+ isListening: false,
12
+ lastResult: '',
13
+ isReady: false,
14
+ adapterType: null,
15
+ initialize: vi.fn(),
16
+ start: vi.fn(),
17
+ stop: vi.fn(),
18
+ }),
19
+ }));
20
+ import Fixture from './form-with-ui-controls.fixture.svelte';
21
+ function captureTools() {
22
+ const registered = [];
23
+ document.modelContext = {
24
+ async registerTool(tool) {
25
+ registered.push(tool);
26
+ },
27
+ };
28
+ return registered;
29
+ }
30
+ afterEach(() => {
31
+ delete document.modelContext;
32
+ });
33
+ describe('rich Form: native attributes, enhance, and smrt-ui controls', () => {
34
+ it('passes native form attributes and attachments through to <form>', async () => {
35
+ const attach = vi.fn();
36
+ render(Fixture, { props: { attach } });
37
+ const form = screen.getByTestId('network-form');
38
+ expect(form.tagName).toBe('FORM');
39
+ expect(form).toHaveAttribute('enctype', 'multipart/form-data');
40
+ expect(form).toHaveAttribute('novalidate');
41
+ expect(form).toHaveAttribute('method', 'POST');
42
+ expect(form).toHaveAttribute('action', '?/create');
43
+ expect(form).toHaveAccessibleName('Set up your network');
44
+ expect(attach).toHaveBeenCalledWith(form);
45
+ });
46
+ it('applies an enhance action to the form and tears it down on unmount', () => {
47
+ const destroy = vi.fn();
48
+ const enhance = vi.fn(() => ({ destroy }));
49
+ const { unmount } = render(Fixture, { props: { enhance } });
50
+ expect(enhance).toHaveBeenCalledWith(screen.getByTestId('network-form'));
51
+ unmount();
52
+ expect(destroy).toHaveBeenCalledTimes(1);
53
+ });
54
+ it('lets an enhanced submit through without preventing it', async () => {
55
+ let submitted;
56
+ const enhance = (form) => {
57
+ const listener = (event) => {
58
+ submitted = event;
59
+ event.preventDefault();
60
+ };
61
+ form.addEventListener('submit', listener);
62
+ return { destroy: () => form.removeEventListener('submit', listener) };
63
+ };
64
+ render(Fixture, { props: { enhance } });
65
+ await userEvent.click(screen.getByRole('button', { name: 'Create' }));
66
+ expect(submitted).toBeDefined();
67
+ });
68
+ it('describes smrt-ui controls and rich fields in one stage-only tool', async () => {
69
+ const registered = captureTools();
70
+ render(Fixture);
71
+ await tick();
72
+ await vi.waitFor(() => expect(registered.length).toBeGreaterThan(0));
73
+ const tool = registered.at(-1);
74
+ expect(tool.name).toBe('setup-network_stage_changes');
75
+ expect(tool.inputSchema).toMatchObject({
76
+ type: 'object',
77
+ properties: {
78
+ tagline: { type: 'string', title: 'Tagline' },
79
+ name: { type: 'string', title: 'Network name' },
80
+ kind: { type: 'string', title: 'Kind of network' },
81
+ town: {
82
+ type: 'string',
83
+ title: 'Home town',
84
+ enum: ['lacombe', 'blackfalds'],
85
+ },
86
+ },
87
+ });
88
+ });
89
+ it('stages smrt-ui control proposals for review and never writes them', async () => {
90
+ const registered = captureTools();
91
+ const registry = createControlInteractionRegistry();
92
+ render(Fixture, { props: { interactionRegistry: registry } });
93
+ await tick();
94
+ await vi.waitFor(() => expect(registered.length).toBeGreaterThan(0));
95
+ const tool = registered.at(-1);
96
+ const result = await tool.execute({
97
+ name: 'Central Alberta News',
98
+ town: 'blackfalds',
99
+ tagline: 'Local first',
100
+ });
101
+ expect(result).toBe('Staged 3 changes for review');
102
+ expect(screen.getByRole('textbox', { name: 'Network name' })).toHaveValue('');
103
+ await waitFor(() => expect(screen.getByRole('region', { name: 'Review proposed changes' })).toBeInTheDocument());
104
+ expect(registry.get({ formId: 'setup-network', controlId: 'name' })?.state.staged
105
+ ?.value).toBe('Central Alberta News');
106
+ expect(screen.queryByText(/setup-network\//)).not.toBeInTheDocument();
107
+ });
108
+ it('marks a staged smrt-ui proposal stale when the person types over it', async () => {
109
+ const registered = captureTools();
110
+ const registry = createControlInteractionRegistry();
111
+ render(Fixture, { props: { interactionRegistry: registry } });
112
+ await tick();
113
+ await vi.waitFor(() => expect(registered.length).toBeGreaterThan(0));
114
+ await registered.at(-1).execute({ name: 'Proposed' });
115
+ await userEvent.type(screen.getByRole('textbox', { name: 'Network name' }), 'Mine');
116
+ await waitFor(() => expect(registry.get({ formId: 'setup-network', controlId: 'name' })?.state
117
+ .staged?.stale).toBe(true));
118
+ });
119
+ });
@@ -1251,7 +1251,9 @@ describe('Form WebMCP staged-edit intent', () => {
1251
1251
  const fullname = screen.getByRole('textbox', { name: 'Full name' });
1252
1252
  expect(fullname).toHaveAttribute('data-smrt-subject-type', 'person');
1253
1253
  expect(fullname).toHaveAttribute('data-smrt-subject-id', 'person-1');
1254
- expect(await screen.findByText(/person:person-1/)).toBeInTheDocument();
1254
+ // The review names the field, not the registry subject (plain labels).
1255
+ expect(await screen.findByRole('button', { name: 'Apply Full name' })).toBeInTheDocument();
1256
+ expect(screen.queryByText(/person:person-1/)).not.toBeInTheDocument();
1255
1257
  await view.rerender({
1256
1258
  webmcp: true,
1257
1259
  showAge: false,
@@ -1263,12 +1265,12 @@ describe('Form WebMCP staged-edit intent', () => {
1263
1265
  await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
1264
1266
  expect(await registered.at(-1)?.execute({ fullname: 'Grace' })).toBe('Staged 1 change for review');
1265
1267
  expect(fullname).toHaveAttribute('data-smrt-subject-id', 'person-2');
1266
- expect(await screen.findByText(/person:person-2/)).toBeInTheDocument();
1268
+ expect(await screen.findByRole('button', { name: 'Apply Full name' })).toBeInTheDocument();
1267
1269
  await userEvent.click(screen.getByRole('button', { name: 'Mutate subject' }));
1268
1270
  await tick();
1269
1271
  expect(await registered.at(-1)?.execute({ fullname: 'Katherine' })).toBe('Staged 1 change for review');
1270
1272
  expect(fullname).toHaveAttribute('data-smrt-subject-id', 'person-mutated');
1271
- expect(await screen.findByText(/person:person-mutated/)).toBeInTheDocument();
1273
+ expect(await screen.findByRole('button', { name: 'Apply Full name' })).toBeInTheDocument();
1272
1274
  });
1273
1275
  it('publishes money in integer minor units and applies cents without rescaling', async () => {
1274
1276
  const registered = [];