@assistant-ui/react-generative-ui 0.0.21 → 0.0.22

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 (189) hide show
  1. package/LICENSE +1 -1
  2. package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
  3. package/dist/JSONGenerativeUI.client.js +30 -13
  4. package/dist/JSONGenerativeUI.client.js.map +1 -1
  5. package/dist/RadioGroupScope.d.ts +6 -0
  6. package/dist/RadioGroupScope.d.ts.map +1 -0
  7. package/dist/RadioGroupScope.js +25 -0
  8. package/dist/RadioGroupScope.js.map +1 -0
  9. package/dist/a2ui/BindingContext.d.ts +11 -0
  10. package/dist/a2ui/BindingContext.d.ts.map +1 -0
  11. package/dist/a2ui/BindingContext.js +12 -0
  12. package/dist/a2ui/BindingContext.js.map +1 -0
  13. package/dist/a2ui/PresentRenderer.d.ts +11 -0
  14. package/dist/a2ui/PresentRenderer.d.ts.map +1 -0
  15. package/dist/a2ui/PresentRenderer.js +39 -0
  16. package/dist/a2ui/PresentRenderer.js.map +1 -0
  17. package/dist/a2ui/convert.d.ts +6 -0
  18. package/dist/a2ui/convert.d.ts.map +1 -1
  19. package/dist/a2ui/convert.js +344 -83
  20. package/dist/a2ui/convert.js.map +1 -1
  21. package/dist/a2ui/dataModel.d.ts +9 -0
  22. package/dist/a2ui/dataModel.d.ts.map +1 -0
  23. package/dist/a2ui/dataModel.js +44 -0
  24. package/dist/a2ui/dataModel.js.map +1 -0
  25. package/dist/a2ui/reducer.d.ts +5 -0
  26. package/dist/a2ui/reducer.d.ts.map +1 -1
  27. package/dist/a2ui/reducer.js +3 -3
  28. package/dist/a2ui/reducer.js.map +1 -1
  29. package/dist/a2ui/types.d.ts +8 -2
  30. package/dist/a2ui/types.d.ts.map +1 -1
  31. package/dist/a2ui/types.js.map +1 -1
  32. package/dist/a2ui/valueFunctions.d.ts +16 -0
  33. package/dist/a2ui/valueFunctions.d.ts.map +1 -0
  34. package/dist/a2ui/valueFunctions.js +332 -0
  35. package/dist/a2ui/valueFunctions.js.map +1 -0
  36. package/dist/actionRegistry.d.ts +1 -1
  37. package/dist/actionRegistry.js.map +1 -1
  38. package/dist/answeredValues.d.ts +16 -0
  39. package/dist/answeredValues.d.ts.map +1 -0
  40. package/dist/answeredValues.js +37 -0
  41. package/dist/answeredValues.js.map +1 -0
  42. package/dist/constants.d.ts +2 -0
  43. package/dist/constants.d.ts.map +1 -1
  44. package/dist/constants.js +3 -1
  45. package/dist/constants.js.map +1 -1
  46. package/dist/fieldReferences.d.ts +9 -0
  47. package/dist/fieldReferences.d.ts.map +1 -0
  48. package/dist/fieldReferences.js +53 -0
  49. package/dist/fieldReferences.js.map +1 -0
  50. package/dist/generativeUIToJSX.js +3 -3
  51. package/dist/generativeUIToJSX.js.map +1 -1
  52. package/dist/index.js +2 -2
  53. package/dist/index.server.js +2 -2
  54. package/dist/index.shared.js +2 -2
  55. package/dist/ir.d.ts +1 -1
  56. package/dist/ir.js.map +1 -1
  57. package/dist/renderGenerativeUI.d.ts.map +1 -1
  58. package/dist/renderGenerativeUI.js +12 -1
  59. package/dist/renderGenerativeUI.js.map +1 -1
  60. package/dist/slack/constants.d.ts +2 -0
  61. package/dist/slack/constants.d.ts.map +1 -1
  62. package/dist/slack/constants.js +3 -1
  63. package/dist/slack/constants.js.map +1 -1
  64. package/dist/slack/decodeBlockAction.d.ts +3 -1
  65. package/dist/slack/decodeBlockAction.d.ts.map +1 -1
  66. package/dist/slack/decodeBlockAction.js +58 -7
  67. package/dist/slack/decodeBlockAction.js.map +1 -1
  68. package/dist/slack/fromSlackBlocks.d.ts.map +1 -1
  69. package/dist/slack/fromSlackBlocks.js +20 -3
  70. package/dist/slack/fromSlackBlocks.js.map +1 -1
  71. package/dist/slack/toSlackBlocks.d.ts.map +1 -1
  72. package/dist/slack/toSlackBlocks.js +187 -19
  73. package/dist/slack/toSlackBlocks.js.map +1 -1
  74. package/dist/slack/types.d.ts +4 -0
  75. package/dist/slack/types.d.ts.map +1 -1
  76. package/dist/teams/constants.d.ts +1 -1
  77. package/dist/teams/constants.d.ts.map +1 -1
  78. package/dist/teams/constants.js +1 -1
  79. package/dist/teams/constants.js.map +1 -1
  80. package/dist/teams/decodeSubmitData.d.ts +4 -0
  81. package/dist/teams/decodeSubmitData.d.ts.map +1 -1
  82. package/dist/teams/decodeSubmitData.js +6 -1
  83. package/dist/teams/decodeSubmitData.js.map +1 -1
  84. package/dist/teams/toAdaptiveCard.d.ts +5 -4
  85. package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
  86. package/dist/teams/toAdaptiveCard.js +56 -17
  87. package/dist/teams/toAdaptiveCard.js.map +1 -1
  88. package/dist/teams/types.d.ts +4 -1
  89. package/dist/teams/types.d.ts.map +1 -1
  90. package/dist/vocabulary/alert.d.ts +9 -2
  91. package/dist/vocabulary/alert.d.ts.map +1 -1
  92. package/dist/vocabulary/alert.js +116 -19
  93. package/dist/vocabulary/alert.js.map +1 -1
  94. package/dist/vocabulary/collectFormValues.d.ts +2 -1
  95. package/dist/vocabulary/collectFormValues.d.ts.map +1 -1
  96. package/dist/vocabulary/collectFormValues.js +10 -3
  97. package/dist/vocabulary/collectFormValues.js.map +1 -1
  98. package/dist/vocabulary/data.d.ts +22 -5
  99. package/dist/vocabulary/data.d.ts.map +1 -1
  100. package/dist/vocabulary/data.js +278 -83
  101. package/dist/vocabulary/data.js.map +1 -1
  102. package/dist/vocabulary/dispatch.d.ts +3 -2
  103. package/dist/vocabulary/dispatch.d.ts.map +1 -1
  104. package/dist/vocabulary/dispatch.js +33 -4
  105. package/dist/vocabulary/dispatch.js.map +1 -1
  106. package/dist/vocabulary/fact.d.ts +8 -1
  107. package/dist/vocabulary/fact.d.ts.map +1 -1
  108. package/dist/vocabulary/fact.js +37 -11
  109. package/dist/vocabulary/fact.js.map +1 -1
  110. package/dist/vocabulary/form.d.ts.map +1 -1
  111. package/dist/vocabulary/form.js +3 -3
  112. package/dist/vocabulary/form.js.map +1 -1
  113. package/dist/vocabulary/formatValue.d.ts +7 -0
  114. package/dist/vocabulary/formatValue.d.ts.map +1 -0
  115. package/dist/vocabulary/formatValue.js +76 -0
  116. package/dist/vocabulary/formatValue.js.map +1 -0
  117. package/dist/vocabulary/icon.d.ts +1 -1
  118. package/dist/vocabulary/interactive.d.ts +137 -8
  119. package/dist/vocabulary/interactive.d.ts.map +1 -1
  120. package/dist/vocabulary/interactive.js +404 -103
  121. package/dist/vocabulary/interactive.js.map +1 -1
  122. package/dist/vocabulary/layout.d.ts.map +1 -1
  123. package/dist/vocabulary/layout.js +4 -4
  124. package/dist/vocabulary/layout.js.map +1 -1
  125. package/dist/vocabulary/list.js +2 -2
  126. package/dist/vocabulary/list.js.map +1 -1
  127. package/dist/vocabulary/text.d.ts +1 -1
  128. package/package.json +6 -3
  129. package/src/JSONGenerativeUI.client.tsx +84 -40
  130. package/src/JSONGenerativeUI.test.tsx +964 -68
  131. package/src/RadioGroupScope.server.test.tsx +24 -0
  132. package/src/RadioGroupScope.tsx +34 -0
  133. package/src/a2ui/BindingContext.ts +26 -0
  134. package/src/a2ui/PresentRenderer.tsx +83 -0
  135. package/src/a2ui/convert.test.ts +1795 -179
  136. package/src/a2ui/convert.ts +629 -132
  137. package/src/a2ui/dataModel.ts +69 -0
  138. package/src/a2ui/reducer.test.ts +35 -0
  139. package/src/a2ui/reducer.ts +5 -4
  140. package/src/a2ui/types.ts +9 -2
  141. package/src/a2ui/valueFunctions.test.ts +271 -0
  142. package/src/a2ui/valueFunctions.ts +523 -0
  143. package/src/actionRegistry.ts +1 -1
  144. package/src/answeredValues.test.ts +33 -0
  145. package/src/answeredValues.tsx +70 -0
  146. package/src/constants.ts +4 -0
  147. package/src/fieldReferences.test.ts +66 -0
  148. package/src/fieldReferences.ts +83 -0
  149. package/src/generativeUIToJSX.test.ts +18 -0
  150. package/src/generativeUIToJSX.ts +4 -6
  151. package/src/index.server.test.ts +106 -0
  152. package/src/ir.ts +1 -1
  153. package/src/renderGenerativeUI.test.tsx +14 -5
  154. package/src/renderGenerativeUI.tsx +30 -1
  155. package/src/slack/a2uiSlack.test.ts +198 -0
  156. package/src/slack/constants.ts +3 -0
  157. package/src/slack/decodeBlockAction.test.ts +98 -0
  158. package/src/slack/decodeBlockAction.ts +110 -17
  159. package/src/slack/fromSlackBlocks.test.ts +222 -3
  160. package/src/slack/fromSlackBlocks.ts +53 -2
  161. package/src/slack/toSlackBlocks.test.ts +682 -3
  162. package/src/slack/toSlackBlocks.ts +374 -23
  163. package/src/slack/types.ts +4 -0
  164. package/src/teams/constants.ts +1 -1
  165. package/src/teams/decodeSubmitData.test.ts +33 -0
  166. package/src/teams/decodeSubmitData.ts +9 -1
  167. package/src/teams/toAdaptiveCard.test.ts +219 -0
  168. package/src/teams/toAdaptiveCard.ts +93 -19
  169. package/src/teams/types.ts +4 -1
  170. package/src/vocabulary/alert.test.tsx +288 -7
  171. package/src/vocabulary/alert.tsx +168 -26
  172. package/src/vocabulary/collectFormValues.test.ts +28 -1
  173. package/src/vocabulary/collectFormValues.ts +25 -5
  174. package/src/vocabulary/data.test.tsx +248 -6
  175. package/src/vocabulary/data.tsx +398 -100
  176. package/src/vocabulary/dispatch.ts +63 -2
  177. package/src/vocabulary/fact.test.tsx +67 -0
  178. package/src/vocabulary/fact.tsx +40 -9
  179. package/src/vocabulary/form.tsx +7 -2
  180. package/src/vocabulary/formatValue.test.ts +72 -0
  181. package/src/vocabulary/formatValue.ts +127 -0
  182. package/src/vocabulary/index.test.ts +2 -0
  183. package/src/vocabulary/interactive.dispatch.test.tsx +167 -79
  184. package/src/vocabulary/interactive.dom.test.tsx +1699 -0
  185. package/src/vocabulary/interactive.test.tsx +60 -4
  186. package/src/vocabulary/interactive.tsx +770 -151
  187. package/src/vocabulary/layout.test.tsx +6 -4
  188. package/src/vocabulary/layout.tsx +13 -2
  189. package/src/vocabulary/list.tsx +2 -2
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { UIElement } from "../ir";
3
- import { convertSurfaceToUISpec } from "./convert";
3
+ import { resolveFieldReferences } from "../fieldReferences";
4
+ import { convertSurfaceToUISpec, createLiveSurfaceConverter } from "./convert";
4
5
  import { applyA2uiOperations } from "./reducer";
5
6
  import { surfaceToOperations } from "./snapshot";
6
7
  import type { A2uiSurfaceState } from "./types";
@@ -19,6 +20,82 @@ const surfaceFrom = (
19
20
  });
20
21
 
21
22
  describe("convertSurfaceToUISpec", () => {
23
+ it("keeps absolute bindings rooted while resolving relative template bindings locally", () => {
24
+ const surface = surfaceFrom(
25
+ [
26
+ {
27
+ id: "root",
28
+ component: "List",
29
+ children: { componentId: "row", path: "/items" },
30
+ },
31
+ {
32
+ id: "row",
33
+ component: "Row",
34
+ children: ["absolute", "relative", "send", "nested"],
35
+ },
36
+ {
37
+ id: "absolute",
38
+ component: "TextField",
39
+ value: { path: "/currency" },
40
+ },
41
+ { id: "relative", component: "TextField", value: { path: "currency" } },
42
+ {
43
+ id: "send",
44
+ component: "Button",
45
+ label: "Send",
46
+ action: {
47
+ event: {
48
+ name: "send",
49
+ context: {
50
+ absolute: { path: "/currency" },
51
+ relative: { path: "currency" },
52
+ },
53
+ },
54
+ },
55
+ },
56
+ {
57
+ id: "nested",
58
+ component: "List",
59
+ children: { componentId: "label", path: "/labels" },
60
+ },
61
+ { id: "label", component: "Text", text: { path: "name" } },
62
+ ],
63
+ {
64
+ currency: "USD",
65
+ labels: [{ name: "Root label" }],
66
+ items: [{ currency: "EUR", labels: [{ name: "Wrong label" }] }],
67
+ },
68
+ );
69
+
70
+ const result = convertSurfaceToUISpec(surface);
71
+ expect(result.warnings).toEqual([]);
72
+ expect(result.spec).toMatchObject({
73
+ children: [
74
+ {
75
+ children: {
76
+ children: [
77
+ { $type: "Input", name: "/currency", defaultValue: "USD" },
78
+ {
79
+ $type: "Input",
80
+ name: "/items/0/currency",
81
+ defaultValue: "EUR",
82
+ },
83
+ {
84
+ $action: {
85
+ context: {
86
+ absolute: { $field: "/currency", fallback: "USD" },
87
+ relative: { $field: "/items/0/currency", fallback: "EUR" },
88
+ },
89
+ },
90
+ },
91
+ { children: [{ children: { value: "Root label" } }] },
92
+ ],
93
+ },
94
+ },
95
+ ],
96
+ });
97
+ });
98
+
22
99
  it("replays a surface with bindings, templates, and custom components", () => {
23
100
  const initial = applyA2uiOperations(new Map(), [
24
101
  {
@@ -38,14 +115,12 @@ describe("convertSurfaceToUISpec", () => {
38
115
  {
39
116
  id: "items",
40
117
  component: "List",
41
- children: {
42
- template: { componentId: "item", path: "/items" },
43
- },
118
+ children: { componentId: "item", path: "/items" },
44
119
  },
45
120
  {
46
121
  id: "item",
47
122
  component: "Text",
48
- text: { path: "/name" },
123
+ text: { path: "name" },
49
124
  },
50
125
  {
51
126
  id: "custom",
@@ -262,12 +337,13 @@ describe("convertSurfaceToUISpec", () => {
262
337
  $type: "Input",
263
338
  label: "Email",
264
339
  placeholder: "you@example.com",
265
- name: "email",
340
+ name: "/form/email",
341
+ defaultValue: "ada@example.com",
266
342
  },
267
343
  {
268
344
  $type: "Checkbox",
269
345
  label: "Accepted",
270
- name: "accepted",
346
+ name: "/form/accepted",
271
347
  defaultChecked: true,
272
348
  },
273
349
  ],
@@ -305,7 +381,6 @@ describe("convertSurfaceToUISpec", () => {
305
381
  id: "select",
306
382
  component: "ChoicePicker",
307
383
  label: "Delivery",
308
- placeholder: "Choose delivery",
309
384
  variant: "multipleSelection",
310
385
  value: { path: "/form/delivery" },
311
386
  options: [
@@ -363,14 +438,14 @@ describe("convertSurfaceToUISpec", () => {
363
438
  children: [{ $type: "Markdown", value: "Horizontal item" }],
364
439
  },
365
440
  {
366
- $type: "Select",
441
+ $type: "CheckboxGroup",
367
442
  options: [
368
443
  { label: "Express", value: "express" },
369
444
  { label: "Standard", value: "standard" },
370
445
  ],
371
- placeholder: "Choose delivery",
372
446
  label: "Delivery",
373
- name: "delivery",
447
+ name: "/form/delivery",
448
+ defaultValue: ["express"],
374
449
  },
375
450
  {
376
451
  $type: "RadioGroup",
@@ -379,7 +454,7 @@ describe("convertSurfaceToUISpec", () => {
379
454
  { label: "Pro", value: "pro" },
380
455
  ],
381
456
  label: "Plan",
382
- name: "plan",
457
+ name: "/form/plan",
383
458
  defaultValue: "pro",
384
459
  },
385
460
  {
@@ -388,7 +463,65 @@ describe("convertSurfaceToUISpec", () => {
388
463
  min: "2026-01-01",
389
464
  max: "2026-12-31",
390
465
  label: "Start date",
391
- name: "startDate",
466
+ name: "/form/startDate",
467
+ },
468
+ ],
469
+ },
470
+ warnings: [],
471
+ });
472
+ });
473
+
474
+ it("maps an omitted ChoicePicker variant as mutuallyExclusive and multiple selection chips as a CheckboxGroup", () => {
475
+ const surface = surfaceFrom(
476
+ [
477
+ { id: "root", component: "Column", children: ["plan", "toppings"] },
478
+ {
479
+ id: "plan",
480
+ component: "ChoicePicker",
481
+ options: [
482
+ { label: "Free", value: "free" },
483
+ { label: "Pro", value: "pro" },
484
+ ],
485
+ value: { path: "/plan" },
486
+ },
487
+ {
488
+ id: "toppings",
489
+ component: "ChoicePicker",
490
+ variant: "multipleSelection",
491
+ displayStyle: "chips",
492
+ options: [
493
+ { label: "Basil", value: "basil" },
494
+ { label: "Olives", value: "olives" },
495
+ { label: "Onion", value: "onion" },
496
+ ],
497
+ value: { path: "/toppings" },
498
+ },
499
+ ],
500
+ { plan: ["pro"], toppings: ["basil", "onion"] },
501
+ );
502
+
503
+ expect(convertSurfaceToUISpec(surface)).toEqual({
504
+ spec: {
505
+ $type: "Col",
506
+ children: [
507
+ {
508
+ $type: "RadioGroup",
509
+ options: [
510
+ { label: "Free", value: "free" },
511
+ { label: "Pro", value: "pro" },
512
+ ],
513
+ name: "/plan",
514
+ defaultValue: "pro",
515
+ },
516
+ {
517
+ $type: "CheckboxGroup",
518
+ options: [
519
+ { label: "Basil", value: "basil" },
520
+ { label: "Olives", value: "olives" },
521
+ { label: "Onion", value: "onion" },
522
+ ],
523
+ name: "/toppings",
524
+ defaultValue: ["basil", "onion"],
392
525
  },
393
526
  ],
394
527
  },
@@ -396,13 +529,14 @@ describe("convertSurfaceToUISpec", () => {
396
529
  });
397
530
  });
398
531
 
399
- it("does not pass a ChoicePicker value to Select without an initial-value prop", () => {
532
+ it("passes a chips ChoicePicker value to RadioGroup as its initial value", () => {
400
533
  const result = convertSurfaceToUISpec(
401
534
  surfaceFrom([
402
535
  {
403
536
  id: "root",
404
537
  component: "ChoicePicker",
405
- value: "express",
538
+ displayStyle: "chips",
539
+ value: ["express"],
406
540
  options: [{ label: "Express", value: "express" }],
407
541
  },
408
542
  ]),
@@ -410,13 +544,267 @@ describe("convertSurfaceToUISpec", () => {
410
544
 
411
545
  expect(result).toEqual({
412
546
  spec: {
413
- $type: "Select",
547
+ $type: "RadioGroup",
414
548
  options: [{ label: "Express", value: "express" }],
549
+ defaultValue: "express",
415
550
  },
416
551
  warnings: [],
417
552
  });
418
553
  });
419
554
 
555
+ it.each([
556
+ ["obscured", "password", "s3cret"],
557
+ ["obscured", "password", ""],
558
+ ["number", "number", "12.5"],
559
+ ["number", "number", ""],
560
+ ])(
561
+ "maps a %s TextField to a %s Input holding %j",
562
+ (variant, inputType, value) => {
563
+ const result = convertSurfaceToUISpec(
564
+ surfaceFrom(
565
+ [
566
+ { id: "root", component: "Column", children: ["field", "send"] },
567
+ {
568
+ id: "field",
569
+ component: "TextField",
570
+ variant,
571
+ label: "Value",
572
+ value: { path: "/form/value" },
573
+ },
574
+ {
575
+ id: "send",
576
+ component: "Button",
577
+ action: {
578
+ event: { name: "send", context: { form: { path: "/form" } } },
579
+ },
580
+ },
581
+ ],
582
+ { form: { value } },
583
+ ),
584
+ );
585
+
586
+ expect(result.warnings).toEqual([]);
587
+ expect(result.spec?.children).toEqual([
588
+ {
589
+ $type: "Input",
590
+ inputType,
591
+ label: "Value",
592
+ name: "/form/value",
593
+ defaultValue: value,
594
+ },
595
+ {
596
+ $type: "Button",
597
+ $action: {
598
+ type: "a2ui:action",
599
+ name: "send",
600
+ surfaceId: "",
601
+ sourceComponentId: "send",
602
+ context: {
603
+ form: { value: { $field: "/form/value", fallback: value } },
604
+ },
605
+ },
606
+ },
607
+ ]);
608
+ },
609
+ );
610
+
611
+ it.each([
612
+ { mode: {}, value: "2025-12-15", type: "DatePicker" },
613
+ {
614
+ mode: { enableDate: false, enableTime: false },
615
+ value: "2025-12-15",
616
+ type: "DatePicker",
617
+ },
618
+ { mode: { enableDate: true }, value: "2025-12-15", type: "DatePicker" },
619
+ {
620
+ mode: { enableDate: true, enableTime: false },
621
+ value: "2025-12-15",
622
+ type: "DatePicker",
623
+ },
624
+ { mode: { enableDate: true }, value: "", type: "DatePicker" },
625
+ { mode: { enableDate: true }, value: undefined, type: "DatePicker" },
626
+ { mode: {}, value: undefined, type: "DatePicker" },
627
+ { mode: {}, value: "2025-12-15T17:00:00Z", type: "Input" },
628
+ {
629
+ mode: { enableDate: true },
630
+ value: "2025-12-15T17:00:00Z",
631
+ type: "Input",
632
+ },
633
+ {
634
+ mode: { enableDate: true, enableTime: true },
635
+ value: "2025-12-15T17:00:00Z",
636
+ type: "Input",
637
+ },
638
+ {
639
+ mode: { enableDate: true, enableTime: true },
640
+ value: "2025-12-15",
641
+ type: "Input",
642
+ },
643
+ { mode: { enableTime: true }, value: "17:00:00", type: "Input" },
644
+ {
645
+ mode: { enableDate: false, enableTime: true },
646
+ value: "17:00:00",
647
+ type: "Input",
648
+ },
649
+ {
650
+ mode: { enableDate: true },
651
+ value: "2025-12-15T17:00:00+02:00",
652
+ type: "Input",
653
+ },
654
+ ])(
655
+ "maps DateTimeInput $mode holding $value to $type with live field references",
656
+ ({ mode, value, type }) => {
657
+ const reference = {
658
+ $field: "/form/due",
659
+ ...(value !== undefined ? { fallback: value } : {}),
660
+ };
661
+ const result = convertSurfaceToUISpec(
662
+ surfaceFrom(
663
+ [
664
+ {
665
+ id: "root",
666
+ component: "Column",
667
+ children: ["due", "send", "call"],
668
+ },
669
+ {
670
+ id: "due",
671
+ component: "DateTimeInput",
672
+ ...mode,
673
+ label: "Due",
674
+ value: { path: "/form/due" },
675
+ min: "2025-01-01",
676
+ max: "2025-12-31",
677
+ },
678
+ {
679
+ id: "send",
680
+ component: "Button",
681
+ action: {
682
+ event: {
683
+ name: "send",
684
+ context: {
685
+ due: { path: "/form/due" },
686
+ form: { path: "/form" },
687
+ },
688
+ userMessage: { path: "/form/due" },
689
+ },
690
+ },
691
+ },
692
+ {
693
+ id: "call",
694
+ component: "Button",
695
+ action: {
696
+ functionCall: {
697
+ call: "save",
698
+ args: { due: { path: "/form/due" } },
699
+ },
700
+ },
701
+ },
702
+ ],
703
+ { form: value === undefined ? {} : { due: value } },
704
+ ),
705
+ );
706
+
707
+ expect(result.warnings).toEqual([]);
708
+ expect(result.spec?.children).toEqual([
709
+ {
710
+ $type: type,
711
+ label: "Due",
712
+ name: "/form/due",
713
+ ...(value !== undefined
714
+ ? { [type === "DatePicker" ? "value" : "defaultValue"]: value }
715
+ : {}),
716
+ ...(type === "DatePicker"
717
+ ? { min: "2025-01-01", max: "2025-12-31" }
718
+ : {}),
719
+ },
720
+ {
721
+ $type: "Button",
722
+ $action: {
723
+ type: "a2ui:action",
724
+ name: "send",
725
+ surfaceId: "",
726
+ sourceComponentId: "send",
727
+ context: { due: reference, form: { due: reference } },
728
+ userMessage: reference,
729
+ },
730
+ },
731
+ {
732
+ $type: "Button",
733
+ $action: {
734
+ type: "a2ui:functionCall",
735
+ call: "save",
736
+ surfaceId: "",
737
+ sourceComponentId: "call",
738
+ args: { due: reference },
739
+ },
740
+ },
741
+ ]);
742
+ },
743
+ );
744
+
745
+ it.each([undefined, "mutuallyExclusive"])(
746
+ "keeps chips with variant %s list-valued when empty and selected",
747
+ (variant) => {
748
+ for (const value of [[], ["b"]]) {
749
+ const options = [
750
+ { label: "A", value: "a" },
751
+ { label: "B", value: "b" },
752
+ ];
753
+ const result = convertSurfaceToUISpec(
754
+ surfaceFrom(
755
+ [
756
+ { id: "root", component: "Column", children: ["choice", "send"] },
757
+ {
758
+ id: "choice",
759
+ component: "ChoicePicker",
760
+ variant,
761
+ displayStyle: "chips",
762
+ options,
763
+ value: { path: "/form/choice" },
764
+ },
765
+ {
766
+ id: "send",
767
+ component: "Button",
768
+ action: {
769
+ name: "send",
770
+ context: { choice: { path: "/form/choice" } },
771
+ },
772
+ },
773
+ ],
774
+ { form: { choice: value } },
775
+ ),
776
+ );
777
+ const reference = {
778
+ $field: "/form/choice",
779
+ ...(value[0] !== undefined ? { fallback: value[0] } : {}),
780
+ };
781
+ expect(result.warnings).toEqual([]);
782
+ expect(result.spec?.children).toEqual([
783
+ {
784
+ $type: "RadioGroup",
785
+ name: "/form/choice",
786
+ options,
787
+ ...(value[0] !== undefined ? { defaultValue: value[0] } : {}),
788
+ },
789
+ {
790
+ $type: "Button",
791
+ $action: {
792
+ type: "a2ui:action",
793
+ name: "send",
794
+ surfaceId: "",
795
+ sourceComponentId: "send",
796
+ context: { choice: [reference] },
797
+ },
798
+ },
799
+ ]);
800
+ expect(resolveFieldReferences([reference], {})).toEqual(value);
801
+ expect(
802
+ resolveFieldReferences([reference], { "/form/choice": "b" }),
803
+ ).toEqual(["b"]);
804
+ }
805
+ },
806
+ );
807
+
420
808
  it("drops an unresolvable bound prop without throwing", () => {
421
809
  const surface = surfaceFrom([
422
810
  {
@@ -432,133 +820,669 @@ describe("convertSurfaceToUISpec", () => {
432
820
  });
433
821
  });
434
822
 
435
- it("preserves prototype-named action context fields", () => {
436
- const context = JSON.parse(
437
- '{"__proto__":{"admin":true},"literal":"kept"}',
438
- ) as Record<string, unknown>;
439
- const surface = surfaceFrom([
823
+ it("evaluates v1.0 formatString values against the data model", () => {
824
+ const { state } = applyA2uiOperations(new Map(), [
440
825
  {
441
- id: "root",
442
- component: "Button",
443
- label: "Run",
444
- action: { name: "run", context },
826
+ version: "v1.0",
827
+ createSurface: {
828
+ surfaceId: "functions",
829
+ components: [
830
+ {
831
+ id: "root",
832
+ component: "Text",
833
+ text: {
834
+ call: "formatString",
835
+ args: { value: "Hello ${/name}" },
836
+ },
837
+ },
838
+ ],
839
+ dataModel: { name: "Ada" },
840
+ },
445
841
  },
446
842
  ]);
447
843
 
448
- const result = convertSurfaceToUISpec(surface);
449
- const convertedContext = (result.spec as UIElement).$action
450
- ?.context as Record<string, unknown>;
451
-
452
- expect(Object.getPrototypeOf(convertedContext)).toBe(Object.prototype);
453
- expect(Object.hasOwn(convertedContext, "__proto__")).toBe(true);
454
- expect(convertedContext["__proto__"]).toEqual({ admin: true });
455
- expect(JSON.stringify(convertedContext)).toBe(
456
- '{"__proto__":{"admin":true},"literal":"kept"}',
457
- );
458
- });
459
-
460
- it("does not read component fields through a prototype-named prop", () => {
461
- const component = JSON.parse(
462
- '{"id":"root","component":"Text","__proto__":{"text":"injected"}}',
463
- ) as Record<string, unknown>;
464
-
465
- expect(convertSurfaceToUISpec(surfaceFrom([component]))).toEqual({
466
- spec: { $type: "Markdown" },
844
+ expect(convertSurfaceToUISpec(state.get("functions")!)).toEqual({
845
+ spec: { $type: "Markdown", value: "Hello Ada" },
467
846
  warnings: [],
468
847
  });
469
848
  });
470
849
 
471
- it("expands template children relative to each bound item", () => {
850
+ it("evaluates nested value calls and warns when a value function is unknown", () => {
472
851
  const surface = surfaceFrom(
473
852
  [
474
853
  {
475
854
  id: "root",
476
855
  component: "Column",
477
- children: {
478
- template: { componentId: "item", path: "/items" },
856
+ children: ["message", "enabled", "unknown"],
857
+ },
858
+ {
859
+ id: "message",
860
+ component: "Text",
861
+ text: {
862
+ call: "formatString",
863
+ args: {
864
+ value:
865
+ "${formatNumber(value:${/count}, decimals:0, grouping:false)} items",
866
+ },
479
867
  },
480
868
  },
481
869
  {
482
- id: "item",
870
+ id: "enabled",
871
+ component: "CheckBox",
872
+ label: "Enabled",
873
+ value: { call: "not", args: { value: { path: "/disabled" } } },
874
+ },
875
+ {
876
+ id: "unknown",
483
877
  component: "Text",
484
- text: { path: "/name" },
878
+ text: { call: "customValue", args: { value: "ignored" } },
485
879
  },
486
880
  ],
487
- { items: [{ name: "one" }, { name: "two" }] },
881
+ { count: 12, disabled: false },
488
882
  );
489
883
 
490
884
  expect(convertSurfaceToUISpec(surface)).toEqual({
491
885
  spec: {
492
- $type: "ListView",
886
+ $type: "Col",
493
887
  children: [
888
+ { $type: "Markdown", value: "12 items" },
494
889
  {
495
- $type: "ListViewItem",
496
- children: { $type: "Markdown", value: "one" },
497
- },
498
- {
499
- $type: "ListViewItem",
500
- children: { $type: "Markdown", value: "two" },
890
+ $type: "Checkbox",
891
+ label: "Enabled",
892
+ defaultChecked: true,
501
893
  },
894
+ { $type: "Markdown" },
502
895
  ],
503
896
  },
504
- warnings: [],
897
+ warnings: [
898
+ 'A2UI function "customValue" is not supported and was skipped.',
899
+ ],
505
900
  });
506
901
  });
507
902
 
508
- it("preserves a horizontal list container when expanding templates", () => {
903
+ it("maps a bound Slider with the spec's min default and a continuous step", () => {
509
904
  const surface = surfaceFrom(
510
905
  [
511
906
  {
512
907
  id: "root",
513
- component: "List",
514
- direction: "horizontal",
515
- align: "center",
516
- children: {
517
- template: { componentId: "item", path: "/items" },
518
- },
908
+ component: "Slider",
909
+ label: "Volume",
910
+ max: 1,
911
+ value: { path: "/volume" },
519
912
  },
520
- { id: "item", component: "Text", text: { path: "/label" } },
521
913
  ],
522
- { items: [{ label: "One" }, { label: "Two" }] },
914
+ { volume: 0.35 },
523
915
  );
524
916
 
525
917
  expect(convertSurfaceToUISpec(surface)).toEqual({
526
918
  spec: {
527
- $type: "Row",
528
- align: "center",
529
- children: [
530
- { $type: "Markdown", value: "One" },
531
- { $type: "Markdown", value: "Two" },
532
- ],
919
+ $type: "Slider",
920
+ min: 0,
921
+ max: 1,
922
+ step: 0.01,
923
+ defaultValue: 0.35,
924
+ label: "Volume",
925
+ name: "/volume",
533
926
  },
534
927
  warnings: [],
535
928
  });
536
929
  });
537
930
 
538
- it("counts only emitted template nodes", () => {
539
- const components = (root: Record<string, unknown>) => [
540
- root,
541
- {
542
- id: "item",
543
- component: "Row",
544
- children: Array.from({ length: 49 }, (_, index) => `divider-${index}`),
545
- },
546
- ...Array.from({ length: 49 }, (_, index) => ({
547
- id: `divider-${index}`,
548
- component: "Divider",
549
- })),
550
- ];
551
- const dataModel = { items: Array.from({ length: 100 }, () => ({})) };
552
- const results = [
553
- convertSurfaceToUISpec(
554
- surfaceFrom(
555
- components({
556
- id: "root",
557
- component: "List",
558
- direction: "horizontal",
559
- children: {
560
- template: { componentId: "item", path: "/items" },
931
+ it("divides a Slider range by its steps and keeps integer values exact", () => {
932
+ const stepped = surfaceFrom([
933
+ { id: "root", component: "Slider", max: 100, steps: 4, value: 50 },
934
+ ]);
935
+ const wide = surfaceFrom([
936
+ { id: "root", component: "Slider", min: 0, max: 1000, value: 537 },
937
+ ]);
938
+
939
+ expect(convertSurfaceToUISpec(stepped).spec).toEqual({
940
+ $type: "Slider",
941
+ min: 0,
942
+ max: 100,
943
+ step: 25,
944
+ defaultValue: 50,
945
+ });
946
+ expect(convertSurfaceToUISpec(wide).spec).toEqual({
947
+ $type: "Slider",
948
+ min: 0,
949
+ max: 1000,
950
+ step: 1,
951
+ defaultValue: 537,
952
+ });
953
+ });
954
+
955
+ it("sends a Slider's live value to an action bound to its path", () => {
956
+ const surface = surfaceFrom(
957
+ [
958
+ { id: "root", component: "Column", children: ["volume", "save"] },
959
+ {
960
+ id: "volume",
961
+ component: "Slider",
962
+ max: 10,
963
+ value: { path: "/volume" },
964
+ },
965
+ {
966
+ id: "save",
967
+ component: "Button",
968
+ label: "Save",
969
+ action: {
970
+ event: { name: "save", context: { volume: { path: "/volume" } } },
971
+ },
972
+ },
973
+ ],
974
+ { volume: 4 },
975
+ );
976
+
977
+ const result = convertSurfaceToUISpec(surface);
978
+ expect(result.warnings).toEqual([]);
979
+ expect(result.spec).toMatchObject({
980
+ children: [
981
+ { $type: "Slider", name: "/volume", defaultValue: 4 },
982
+ {
983
+ $action: {
984
+ context: { volume: { $field: "/volume", fallback: 4 } },
985
+ },
986
+ },
987
+ ],
988
+ });
989
+ });
990
+
991
+ it("skips a Slider without a numeric max", () => {
992
+ const surface = surfaceFrom([
993
+ { id: "root", component: "Slider", value: 3 },
994
+ ]);
995
+
996
+ expect(convertSurfaceToUISpec(surface).warnings).toEqual([
997
+ 'A2UI component "Slider" could not be mapped and was skipped.',
998
+ ]);
999
+ });
1000
+
1001
+ it("warns and omits a malformed formatString template", () => {
1002
+ const surface = surfaceFrom([
1003
+ {
1004
+ id: "root",
1005
+ component: "Text",
1006
+ text: { call: "formatString", args: { value: "Hi ${name" } },
1007
+ },
1008
+ ]);
1009
+
1010
+ expect(convertSurfaceToUISpec(surface)).toEqual({
1011
+ spec: { $type: "Markdown" },
1012
+ warnings: [
1013
+ "A2UI formatString template is malformed: an interpolation is not closed.",
1014
+ ],
1015
+ });
1016
+ });
1017
+
1018
+ it("resolves relative paths in bindings and function arguments against the current item", () => {
1019
+ const usd = (value: number) =>
1020
+ new Intl.NumberFormat(undefined, {
1021
+ style: "currency",
1022
+ currency: "USD",
1023
+ }).format(value);
1024
+ const surface = surfaceFrom(
1025
+ [
1026
+ { id: "root", component: "Column", children: ["total", "rows"] },
1027
+ {
1028
+ id: "total",
1029
+ component: "Text",
1030
+ text: {
1031
+ call: "formatCurrency",
1032
+ args: { value: { path: "total" }, currency: "USD" },
1033
+ },
1034
+ },
1035
+ {
1036
+ id: "rows",
1037
+ component: "Column",
1038
+ children: { componentId: "row", path: "/items" },
1039
+ },
1040
+ {
1041
+ id: "row",
1042
+ component: "Text",
1043
+ text: {
1044
+ call: "formatString",
1045
+ args: {
1046
+ value:
1047
+ "${name}: ${formatCurrency(value: ${price}, currency: 'USD')}",
561
1048
  },
1049
+ },
1050
+ },
1051
+ ],
1052
+ { total: 12, items: [{ name: "Tea", price: 3 }] },
1053
+ );
1054
+
1055
+ expect(convertSurfaceToUISpec(surface)).toEqual({
1056
+ spec: {
1057
+ $type: "Col",
1058
+ children: [
1059
+ { $type: "Markdown", value: usd(12) },
1060
+ {
1061
+ $type: "Col",
1062
+ children: [{ $type: "Markdown", value: `Tea: ${usd(3)}` }],
1063
+ },
1064
+ ],
1065
+ },
1066
+ warnings: [],
1067
+ });
1068
+ });
1069
+
1070
+ it("evaluates values inside actions but leaves a functionCall for the host to run", () => {
1071
+ const surface = surfaceFrom(
1072
+ [
1073
+ { id: "root", component: "Row", children: ["save", "docs"] },
1074
+ {
1075
+ id: "save",
1076
+ component: "Button",
1077
+ label: "Save",
1078
+ action: {
1079
+ event: {
1080
+ name: "save",
1081
+ context: {
1082
+ summary: {
1083
+ call: "formatString",
1084
+ args: { value: "${/count} items" },
1085
+ },
1086
+ },
1087
+ },
1088
+ },
1089
+ },
1090
+ {
1091
+ id: "docs",
1092
+ component: "Button",
1093
+ label: "Docs",
1094
+ action: {
1095
+ functionCall: {
1096
+ call: "openUrl",
1097
+ args: {
1098
+ url: {
1099
+ call: "formatString",
1100
+ args: { value: "https://example.com/${/slug}" },
1101
+ },
1102
+ },
1103
+ },
1104
+ },
1105
+ },
1106
+ ],
1107
+ { count: 2, slug: "a2ui" },
1108
+ );
1109
+
1110
+ expect(convertSurfaceToUISpec(surface)).toEqual({
1111
+ spec: {
1112
+ $type: "Row",
1113
+ children: [
1114
+ {
1115
+ $type: "Button",
1116
+ label: "Save",
1117
+ $action: {
1118
+ type: "a2ui:action",
1119
+ name: "save",
1120
+ surfaceId: "",
1121
+ sourceComponentId: "save",
1122
+ context: { summary: "2 items" },
1123
+ },
1124
+ },
1125
+ {
1126
+ $type: "Button",
1127
+ label: "Docs",
1128
+ $action: {
1129
+ type: "a2ui:functionCall",
1130
+ call: "openUrl",
1131
+ surfaceId: "",
1132
+ sourceComponentId: "docs",
1133
+ args: { url: "https://example.com/a2ui" },
1134
+ },
1135
+ },
1136
+ ],
1137
+ },
1138
+ warnings: [],
1139
+ });
1140
+ });
1141
+
1142
+ it("reads a function's arguments entry by entry, even when they look like a binding or a call", () => {
1143
+ const surface = surfaceFrom([
1144
+ { id: "root", component: "Column", children: ["bound", "nested", "ok"] },
1145
+ {
1146
+ id: "bound",
1147
+ component: "Text",
1148
+ text: { call: "formatNumber", args: { path: "/missing" } },
1149
+ },
1150
+ {
1151
+ id: "nested",
1152
+ component: "Text",
1153
+ text: {
1154
+ call: "formatString",
1155
+ args: { value: "${formatNumber(call: 'x')}" },
1156
+ },
1157
+ },
1158
+ { id: "ok", component: "Text", text: "still here" },
1159
+ ]);
1160
+
1161
+ expect(convertSurfaceToUISpec(surface)).toEqual({
1162
+ spec: {
1163
+ $type: "Col",
1164
+ children: [
1165
+ { $type: "Markdown", value: "" },
1166
+ { $type: "Markdown", value: "" },
1167
+ { $type: "Markdown", value: "still here" },
1168
+ ],
1169
+ },
1170
+ warnings: [],
1171
+ });
1172
+ });
1173
+
1174
+ it("keeps a missing operand in and and or instead of dropping it", () => {
1175
+ const operands = [{ path: "/agreed" }, { path: "/verified" }];
1176
+ const surface = surfaceFrom(
1177
+ [
1178
+ { id: "root", component: "Column", children: ["all", "any"] },
1179
+ {
1180
+ id: "all",
1181
+ component: "CheckBox",
1182
+ label: "All",
1183
+ value: { call: "and", args: { values: operands } },
1184
+ },
1185
+ {
1186
+ id: "any",
1187
+ component: "CheckBox",
1188
+ label: "Any",
1189
+ value: { call: "or", args: { values: operands } },
1190
+ },
1191
+ ],
1192
+ { verified: true },
1193
+ );
1194
+
1195
+ expect(convertSurfaceToUISpec(surface)).toEqual({
1196
+ spec: {
1197
+ $type: "Col",
1198
+ children: [
1199
+ { $type: "Checkbox", label: "All", defaultChecked: false },
1200
+ { $type: "Checkbox", label: "Any", defaultChecked: true },
1201
+ ],
1202
+ },
1203
+ warnings: [],
1204
+ });
1205
+ });
1206
+
1207
+ it("stops evaluating functions once the evaluation budget is spent", () => {
1208
+ const surface = surfaceFrom(
1209
+ [
1210
+ {
1211
+ id: "root",
1212
+ component: "Text",
1213
+ text: { call: "formatString", args: { value: { path: "/t" } } },
1214
+ },
1215
+ ],
1216
+ { t: "${formatString(value: /t)}".repeat(380) },
1217
+ );
1218
+
1219
+ const result = convertSurfaceToUISpec(surface);
1220
+
1221
+ expect(result.spec).toEqual({ $type: "Markdown", value: "" });
1222
+ expect(result.warnings).toEqual([
1223
+ "A2UI function nesting cap of 32 was reached.",
1224
+ "A2UI function evaluation budget of 20000 was reached.",
1225
+ ]);
1226
+ });
1227
+
1228
+ it("keeps an object with a call key and other fields as data", () => {
1229
+ const surface = surfaceFrom([
1230
+ {
1231
+ id: "root",
1232
+ component: "ContactCard",
1233
+ phone: { call: "+1 555 0100", label: "Office" },
1234
+ },
1235
+ ]);
1236
+
1237
+ expect(
1238
+ convertSurfaceToUISpec(surface, { keepUnknownComponents: true }),
1239
+ ).toEqual({
1240
+ spec: {
1241
+ $type: "ContactCard",
1242
+ phone: { call: "+1 555 0100", label: "Office" },
1243
+ },
1244
+ warnings: [],
1245
+ });
1246
+ });
1247
+
1248
+ it("leaves validation check calls outside value evaluation", () => {
1249
+ const surface = surfaceFrom([
1250
+ {
1251
+ id: "root",
1252
+ component: "TextField",
1253
+ label: "Name",
1254
+ checks: [{ call: "required", args: { value: { path: "/name" } } }],
1255
+ },
1256
+ ]);
1257
+
1258
+ expect(convertSurfaceToUISpec(surface)).toEqual({
1259
+ spec: { $type: "Input", label: "Name" },
1260
+ warnings: [],
1261
+ });
1262
+ });
1263
+
1264
+ it("preserves prototype-named action context fields", () => {
1265
+ const context = JSON.parse(
1266
+ '{"__proto__":{"admin":true},"literal":"kept"}',
1267
+ ) as Record<string, unknown>;
1268
+ const surface = surfaceFrom([
1269
+ {
1270
+ id: "root",
1271
+ component: "Button",
1272
+ label: "Run",
1273
+ action: { name: "run", context },
1274
+ },
1275
+ ]);
1276
+
1277
+ const result = convertSurfaceToUISpec(surface);
1278
+ const convertedContext = (result.spec as UIElement).$action
1279
+ ?.context as Record<string, unknown>;
1280
+
1281
+ expect(Object.getPrototypeOf(convertedContext)).toBe(Object.prototype);
1282
+ expect(Object.hasOwn(convertedContext, "__proto__")).toBe(true);
1283
+ expect(convertedContext["__proto__"]).toEqual({ admin: true });
1284
+ expect(JSON.stringify(convertedContext)).toBe(
1285
+ '{"__proto__":{"admin":true},"literal":"kept"}',
1286
+ );
1287
+ });
1288
+
1289
+ it("does not read component fields through a prototype-named prop", () => {
1290
+ const component = JSON.parse(
1291
+ '{"id":"root","component":"Text","__proto__":{"text":"injected"}}',
1292
+ ) as Record<string, unknown>;
1293
+
1294
+ expect(convertSurfaceToUISpec(surfaceFrom([component]))).toEqual({
1295
+ spec: { $type: "Markdown" },
1296
+ warnings: [],
1297
+ });
1298
+ });
1299
+
1300
+ it("expands template children relative to each bound item", () => {
1301
+ const surface = surfaceFrom(
1302
+ [
1303
+ {
1304
+ id: "root",
1305
+ component: "List",
1306
+ children: { componentId: "item", path: "/items" },
1307
+ },
1308
+ {
1309
+ id: "item",
1310
+ component: "Text",
1311
+ text: { path: "name" },
1312
+ },
1313
+ ],
1314
+ { items: [{ name: "one" }, { name: "two" }] },
1315
+ );
1316
+
1317
+ expect(convertSurfaceToUISpec(surface)).toEqual({
1318
+ spec: {
1319
+ $type: "ListView",
1320
+ children: [
1321
+ {
1322
+ $type: "ListViewItem",
1323
+ children: { $type: "Markdown", value: "one" },
1324
+ },
1325
+ {
1326
+ $type: "ListViewItem",
1327
+ children: { $type: "Markdown", value: "two" },
1328
+ },
1329
+ ],
1330
+ },
1331
+ warnings: [],
1332
+ });
1333
+ });
1334
+
1335
+ it("keeps a Row or Column template in its own container", () => {
1336
+ const surface = surfaceFrom(
1337
+ [
1338
+ { id: "root", component: "Column", children: ["days", "hours"] },
1339
+ {
1340
+ id: "days",
1341
+ component: "Row",
1342
+ justify: "spaceBetween",
1343
+ children: { componentId: "day", path: "/days" },
1344
+ },
1345
+ {
1346
+ id: "hours",
1347
+ component: "Column",
1348
+ align: "center",
1349
+ children: { componentId: "day", path: "/hours" },
1350
+ },
1351
+ { id: "day", component: "Text", text: { path: "label" } },
1352
+ ],
1353
+ {
1354
+ days: [{ label: "Tue" }, { label: "Wed" }],
1355
+ hours: [{ label: "9am" }],
1356
+ },
1357
+ );
1358
+
1359
+ expect(convertSurfaceToUISpec(surface)).toEqual({
1360
+ spec: {
1361
+ $type: "Col",
1362
+ children: [
1363
+ {
1364
+ $type: "Row",
1365
+ justify: "spaceBetween",
1366
+ children: [
1367
+ { $type: "Markdown", value: "Tue" },
1368
+ { $type: "Markdown", value: "Wed" },
1369
+ ],
1370
+ },
1371
+ {
1372
+ $type: "Col",
1373
+ align: "center",
1374
+ children: [{ $type: "Markdown", value: "9am" }],
1375
+ },
1376
+ ],
1377
+ },
1378
+ warnings: [],
1379
+ });
1380
+ });
1381
+
1382
+ it("expands a template on a component without a child list into a ListView", () => {
1383
+ const surface = surfaceFrom(
1384
+ [
1385
+ {
1386
+ id: "root",
1387
+ component: "Divider",
1388
+ children: { componentId: "item", path: "/items" },
1389
+ },
1390
+ { id: "item", component: "Text", text: { path: "name" } },
1391
+ ],
1392
+ { items: [{ name: "one" }] },
1393
+ );
1394
+
1395
+ expect(convertSurfaceToUISpec(surface)).toEqual({
1396
+ spec: {
1397
+ $type: "ListView",
1398
+ children: [
1399
+ {
1400
+ $type: "ListViewItem",
1401
+ children: { $type: "Markdown", value: "one" },
1402
+ },
1403
+ ],
1404
+ },
1405
+ warnings: [],
1406
+ });
1407
+ });
1408
+
1409
+ it("reads template children wrapped in a template key", () => {
1410
+ const surface = surfaceFrom(
1411
+ [
1412
+ {
1413
+ id: "root",
1414
+ component: "List",
1415
+ children: { template: { componentId: "item", path: "/items" } },
1416
+ },
1417
+ { id: "item", component: "Text", text: { path: "name" } },
1418
+ ],
1419
+ { items: [{ name: "one" }] },
1420
+ );
1421
+
1422
+ expect(convertSurfaceToUISpec(surface)).toEqual({
1423
+ spec: {
1424
+ $type: "ListView",
1425
+ children: [
1426
+ {
1427
+ $type: "ListViewItem",
1428
+ children: { $type: "Markdown", value: "one" },
1429
+ },
1430
+ ],
1431
+ },
1432
+ warnings: [],
1433
+ });
1434
+ });
1435
+
1436
+ it("preserves a horizontal list container when expanding templates", () => {
1437
+ const surface = surfaceFrom(
1438
+ [
1439
+ {
1440
+ id: "root",
1441
+ component: "List",
1442
+ direction: "horizontal",
1443
+ align: "center",
1444
+ children: { componentId: "item", path: "/items" },
1445
+ },
1446
+ { id: "item", component: "Text", text: { path: "label" } },
1447
+ ],
1448
+ { items: [{ label: "One" }, { label: "Two" }] },
1449
+ );
1450
+
1451
+ expect(convertSurfaceToUISpec(surface)).toEqual({
1452
+ spec: {
1453
+ $type: "Row",
1454
+ align: "center",
1455
+ children: [
1456
+ { $type: "Markdown", value: "One" },
1457
+ { $type: "Markdown", value: "Two" },
1458
+ ],
1459
+ },
1460
+ warnings: [],
1461
+ });
1462
+ });
1463
+
1464
+ it("counts only emitted template nodes", () => {
1465
+ const components = (root: Record<string, unknown>) => [
1466
+ root,
1467
+ {
1468
+ id: "item",
1469
+ component: "Row",
1470
+ children: Array.from({ length: 49 }, (_, index) => `divider-${index}`),
1471
+ },
1472
+ ...Array.from({ length: 49 }, (_, index) => ({
1473
+ id: `divider-${index}`,
1474
+ component: "Divider",
1475
+ })),
1476
+ ];
1477
+ const dataModel = { items: Array.from({ length: 100 }, () => ({})) };
1478
+ const results = [
1479
+ convertSurfaceToUISpec(
1480
+ surfaceFrom(
1481
+ components({
1482
+ id: "root",
1483
+ component: "List",
1484
+ direction: "horizontal",
1485
+ children: { componentId: "item", path: "/items" },
562
1486
  }),
563
1487
  dataModel,
564
1488
  ),
@@ -568,9 +1492,7 @@ describe("convertSurfaceToUISpec", () => {
568
1492
  components({
569
1493
  id: "root",
570
1494
  component: "CustomList",
571
- children: {
572
- template: { componentId: "item", path: "/items" },
573
- },
1495
+ children: { componentId: "item", path: "/items" },
574
1496
  }),
575
1497
  dataModel,
576
1498
  ),
@@ -588,93 +1510,526 @@ describe("convertSurfaceToUISpec", () => {
588
1510
  }
589
1511
  });
590
1512
 
591
- it("caps template expansion at 100 items", () => {
1513
+ it("caps template expansion at 100 items", () => {
1514
+ const surface = surfaceFrom(
1515
+ [
1516
+ {
1517
+ id: "root",
1518
+ component: "Column",
1519
+ children: { componentId: "item", path: "/items" },
1520
+ },
1521
+ {
1522
+ id: "item",
1523
+ component: "Text",
1524
+ text: { path: "/" },
1525
+ },
1526
+ ],
1527
+ { items: Array.from({ length: 101 }, (_, index) => `item-${index}`) },
1528
+ );
1529
+
1530
+ const result = convertSurfaceToUISpec(surface);
1531
+ expect((result.spec as UIElement).children).toHaveLength(100);
1532
+ expect(result.warnings).toContain(
1533
+ "A2UI template expansion was capped at 100 items.",
1534
+ );
1535
+ });
1536
+
1537
+ it("follows single child references and event actions", () => {
1538
+ const surface = surfaceFrom(
1539
+ [
1540
+ { id: "root", component: "Card", child: "column" },
1541
+ {
1542
+ id: "column",
1543
+ component: "Column",
1544
+ children: ["submit", "close"],
1545
+ },
1546
+ {
1547
+ id: "submit",
1548
+ component: "Button",
1549
+ child: "submit-text",
1550
+ action: {
1551
+ event: {
1552
+ name: "submit",
1553
+ context: { carrier: { path: "/carrier" } },
1554
+ },
1555
+ },
1556
+ },
1557
+ { id: "submit-text", component: "Text", text: { path: "/label" } },
1558
+ {
1559
+ id: "close",
1560
+ component: "Button",
1561
+ child: "close-icon",
1562
+ action: { event: { name: "close" } },
1563
+ },
1564
+ { id: "close-icon", component: "Icon", name: "x" },
1565
+ ],
1566
+ { carrier: ["dhl"], label: "Create label" },
1567
+ );
1568
+
1569
+ expect(convertSurfaceToUISpec(surface)).toEqual({
1570
+ spec: {
1571
+ $type: "Card",
1572
+ children: [
1573
+ {
1574
+ $type: "Col",
1575
+ children: [
1576
+ {
1577
+ $type: "Button",
1578
+ label: "Create label",
1579
+ $action: {
1580
+ type: "a2ui:action",
1581
+ name: "submit",
1582
+ surfaceId: "",
1583
+ sourceComponentId: "submit",
1584
+ context: { carrier: ["dhl"] },
1585
+ },
1586
+ },
1587
+ {
1588
+ $type: "Button",
1589
+ $action: {
1590
+ type: "a2ui:action",
1591
+ name: "close",
1592
+ surfaceId: "",
1593
+ sourceComponentId: "close",
1594
+ },
1595
+ children: [{ $type: "Icon", name: "x" }],
1596
+ },
1597
+ ],
1598
+ },
1599
+ ],
1600
+ },
1601
+ warnings: [],
1602
+ });
1603
+ });
1604
+
1605
+ it("carries an event's userMessage when it resolves to a string", () => {
1606
+ const surface = surfaceFrom(
1607
+ [
1608
+ { id: "root", component: "Row", children: ["greet", "count"] },
1609
+ {
1610
+ id: "greet",
1611
+ component: "Button",
1612
+ label: "Greet",
1613
+ action: {
1614
+ event: {
1615
+ name: "greet",
1616
+ userMessage: {
1617
+ call: "formatString",
1618
+ args: { value: "Greet ${/name}" },
1619
+ },
1620
+ },
1621
+ },
1622
+ },
1623
+ {
1624
+ id: "count",
1625
+ component: "Button",
1626
+ label: "Count",
1627
+ action: { event: { name: "count", userMessage: { path: "/count" } } },
1628
+ },
1629
+ ],
1630
+ { name: "Ada", count: 3 },
1631
+ );
1632
+
1633
+ expect(convertSurfaceToUISpec(surface)).toEqual({
1634
+ spec: {
1635
+ $type: "Row",
1636
+ children: [
1637
+ {
1638
+ $type: "Button",
1639
+ label: "Greet",
1640
+ $action: {
1641
+ type: "a2ui:action",
1642
+ name: "greet",
1643
+ surfaceId: "",
1644
+ sourceComponentId: "greet",
1645
+ userMessage: "Greet Ada",
1646
+ },
1647
+ },
1648
+ {
1649
+ $type: "Button",
1650
+ label: "Count",
1651
+ $action: {
1652
+ type: "a2ui:action",
1653
+ name: "count",
1654
+ surfaceId: "",
1655
+ sourceComponentId: "count",
1656
+ },
1657
+ },
1658
+ ],
1659
+ },
1660
+ warnings: [],
1661
+ });
1662
+ });
1663
+
1664
+ it("points a userMessage only at a lone text input bound to its exact path", () => {
1665
+ const surface = surfaceFrom(
1666
+ [
1667
+ {
1668
+ id: "root",
1669
+ component: "Column",
1670
+ children: [
1671
+ "email",
1672
+ "volume",
1673
+ "note",
1674
+ "qty",
1675
+ "qty-level",
1676
+ "form",
1677
+ "level",
1678
+ "draft",
1679
+ "copy",
1680
+ ],
1681
+ },
1682
+ { id: "email", component: "TextField", value: { path: "/form/email" } },
1683
+ {
1684
+ id: "volume",
1685
+ component: "Slider",
1686
+ max: 10,
1687
+ value: { path: "/volume" },
1688
+ },
1689
+ { id: "note", component: "TextField", value: { path: "/note" } },
1690
+ { id: "qty", component: "TextField", value: { path: "/qty" } },
1691
+ {
1692
+ id: "qty-level",
1693
+ component: "Slider",
1694
+ max: 10,
1695
+ value: { path: "/qty" },
1696
+ },
1697
+ {
1698
+ id: "form",
1699
+ component: "Button",
1700
+ label: "Form",
1701
+ action: { event: { name: "form", userMessage: { path: "/form" } } },
1702
+ },
1703
+ {
1704
+ id: "level",
1705
+ component: "Button",
1706
+ label: "Level",
1707
+ action: {
1708
+ event: { name: "level", userMessage: { path: "/volume" } },
1709
+ },
1710
+ },
1711
+ {
1712
+ id: "draft",
1713
+ component: "Button",
1714
+ label: "Draft",
1715
+ action: { event: { name: "draft", userMessage: { path: "/note" } } },
1716
+ },
1717
+ {
1718
+ id: "copy",
1719
+ component: "Button",
1720
+ label: "Copy",
1721
+ action: { event: { name: "copy", userMessage: { path: "/qty" } } },
1722
+ },
1723
+ ],
1724
+ { form: { email: "ada@example.com" }, volume: 4, qty: "2" },
1725
+ );
1726
+
1727
+ const { spec, warnings } = convertSurfaceToUISpec(surface);
1728
+
1729
+ expect(warnings).toEqual([]);
1730
+ expect(spec?.["children"]).toContainEqual({
1731
+ $type: "Button",
1732
+ label: "Form",
1733
+ $action: {
1734
+ type: "a2ui:action",
1735
+ name: "form",
1736
+ surfaceId: "",
1737
+ sourceComponentId: "form",
1738
+ },
1739
+ });
1740
+ expect(spec?.["children"]).toContainEqual({
1741
+ $type: "Button",
1742
+ label: "Level",
1743
+ $action: {
1744
+ type: "a2ui:action",
1745
+ name: "level",
1746
+ surfaceId: "",
1747
+ sourceComponentId: "level",
1748
+ },
1749
+ });
1750
+ expect(spec?.["children"]).toContainEqual({
1751
+ $type: "Button",
1752
+ label: "Draft",
1753
+ $action: {
1754
+ type: "a2ui:action",
1755
+ name: "draft",
1756
+ surfaceId: "",
1757
+ sourceComponentId: "draft",
1758
+ userMessage: { $field: "/note" },
1759
+ },
1760
+ });
1761
+ expect(spec?.["children"]).toContainEqual({
1762
+ $type: "Button",
1763
+ label: "Copy",
1764
+ $action: {
1765
+ type: "a2ui:action",
1766
+ name: "copy",
1767
+ surfaceId: "",
1768
+ sourceComponentId: "copy",
1769
+ userMessage: "2",
1770
+ },
1771
+ });
1772
+ });
1773
+
1774
+ it("takes a button label only from a Text child and keeps action data as bound", () => {
1775
+ const surface = surfaceFrom(
1776
+ [
1777
+ {
1778
+ id: "root",
1779
+ component: "Column",
1780
+ children: ["custom-label", "bound-context"],
1781
+ },
1782
+ { id: "custom-label", component: "Button", child: "note" },
1783
+ { id: "note", component: "Markdown", value: "Read me", tone: "loud" },
1784
+ {
1785
+ id: "bound-context",
1786
+ component: "Button",
1787
+ label: "Go",
1788
+ action: {
1789
+ event: { name: "go", context: { target: { path: "/target" } } },
1790
+ },
1791
+ },
1792
+ ],
1793
+ { target: { path: "/secret" }, secret: "hidden" },
1794
+ );
1795
+
1796
+ expect(
1797
+ convertSurfaceToUISpec(surface, { keepUnknownComponents: true }),
1798
+ ).toEqual({
1799
+ spec: {
1800
+ $type: "Col",
1801
+ children: [
1802
+ {
1803
+ $type: "Button",
1804
+ children: [{ $type: "Markdown", value: "Read me", tone: "loud" }],
1805
+ },
1806
+ {
1807
+ $type: "Button",
1808
+ label: "Go",
1809
+ $action: {
1810
+ type: "a2ui:action",
1811
+ name: "go",
1812
+ surfaceId: "",
1813
+ sourceComponentId: "bound-context",
1814
+ context: { target: { path: "/secret" } },
1815
+ },
1816
+ },
1817
+ ],
1818
+ },
1819
+ warnings: [],
1820
+ });
1821
+ });
1822
+
1823
+ it("points action context bound to an input at that input, so it resolves when the action fires", () => {
592
1824
  const surface = surfaceFrom(
593
1825
  [
594
1826
  {
595
1827
  id: "root",
596
1828
  component: "Column",
597
- children: {
598
- template: { componentId: "item", path: "/items" },
599
- },
1829
+ children: ["name", "plan", "size", "agree", "start", "due", "send"],
600
1830
  },
601
1831
  {
602
- id: "item",
603
- component: "Text",
604
- text: { path: "/" },
1832
+ id: "name",
1833
+ component: "TextField",
1834
+ label: "Name",
1835
+ value: { path: "/form/name" },
1836
+ },
1837
+ {
1838
+ id: "plan",
1839
+ component: "ChoicePicker",
1840
+ options: [{ label: "Pro", value: "pro" }],
1841
+ value: { path: "/form/plan" },
1842
+ },
1843
+ {
1844
+ id: "size",
1845
+ component: "ChoicePicker",
1846
+ displayStyle: "chips",
1847
+ options: [{ label: "Medium", value: "m" }],
1848
+ value: { path: "/form/size" },
1849
+ },
1850
+ {
1851
+ id: "agree",
1852
+ component: "CheckBox",
1853
+ label: "Agree",
1854
+ value: { path: "/form/agree" },
1855
+ },
1856
+ {
1857
+ id: "start",
1858
+ component: "DateTimeInput",
1859
+ value: { path: "/form/start" },
1860
+ },
1861
+ {
1862
+ id: "due",
1863
+ component: "DateTimeInput",
1864
+ value: { path: "/form/due" },
1865
+ },
1866
+ {
1867
+ id: "send",
1868
+ component: "Button",
1869
+ label: "Send",
1870
+ action: {
1871
+ event: {
1872
+ name: "send",
1873
+ context: {
1874
+ name: { path: "/form/name" },
1875
+ form: { path: "/form" },
1876
+ owner: { path: "/owner" },
1877
+ literal: "kept",
1878
+ },
1879
+ },
1880
+ },
605
1881
  },
606
1882
  ],
607
- { items: Array.from({ length: 101 }, (_, index) => `item-${index}`) },
1883
+ {
1884
+ form: {
1885
+ name: "Ada",
1886
+ plan: ["pro"],
1887
+ size: "m",
1888
+ agree: false,
1889
+ start: "2026-01-02",
1890
+ due: "2025-12-15T17:00:00Z",
1891
+ id: 7,
1892
+ },
1893
+ owner: "u1",
1894
+ },
608
1895
  );
609
1896
 
610
- const result = convertSurfaceToUISpec(surface);
611
- expect((result.spec as UIElement).children).toHaveLength(100);
612
- expect(result.warnings).toContain(
613
- "A2UI template expansion was capped at 100 items.",
614
- );
1897
+ const { spec, warnings } = convertSurfaceToUISpec(surface);
1898
+
1899
+ expect(warnings).toEqual([]);
1900
+ expect(spec?.["children"]).toContainEqual({
1901
+ $type: "Button",
1902
+ label: "Send",
1903
+ $action: {
1904
+ type: "a2ui:action",
1905
+ name: "send",
1906
+ surfaceId: "",
1907
+ sourceComponentId: "send",
1908
+ context: {
1909
+ name: { $field: "/form/name", fallback: "Ada" },
1910
+ form: {
1911
+ name: { $field: "/form/name", fallback: "Ada" },
1912
+ plan: [{ $field: "/form/plan", fallback: "pro" }],
1913
+ size: { $field: "/form/size", fallback: "m" },
1914
+ agree: { $field: "/form/agree", fallback: false },
1915
+ start: { $field: "/form/start", fallback: "2026-01-02" },
1916
+ due: { $field: "/form/due", fallback: "2025-12-15T17:00:00Z" },
1917
+ id: 7,
1918
+ },
1919
+ owner: "u1",
1920
+ literal: "kept",
1921
+ },
1922
+ },
1923
+ });
1924
+ expect(surface.dataModel).toEqual({
1925
+ form: {
1926
+ name: "Ada",
1927
+ plan: ["pro"],
1928
+ size: "m",
1929
+ agree: false,
1930
+ start: "2026-01-02",
1931
+ due: "2025-12-15T17:00:00Z",
1932
+ id: 7,
1933
+ },
1934
+ owner: "u1",
1935
+ });
615
1936
  });
616
1937
 
617
- it("follows single child references and event actions", () => {
1938
+ it("names an input inside a template item by the item's path", () => {
618
1939
  const surface = surfaceFrom(
619
1940
  [
620
- { id: "root", component: "Card", child: "column" },
1941
+ { id: "root", component: "Column", children: ["lines", "order"] },
621
1942
  {
622
- id: "column",
623
- component: "Column",
624
- children: ["submit", "close"],
1943
+ id: "lines",
1944
+ component: "List",
1945
+ children: { componentId: "line", path: "/items" },
625
1946
  },
1947
+ { id: "line", component: "Row", children: ["qty", "remove"] },
626
1948
  {
627
- id: "submit",
1949
+ id: "qty",
1950
+ component: "TextField",
1951
+ label: "Quantity",
1952
+ value: { path: "qty" },
1953
+ },
1954
+ {
1955
+ id: "remove",
628
1956
  component: "Button",
629
- child: "submit-text",
1957
+ label: "Remove",
630
1958
  action: {
631
1959
  event: {
632
- name: "submit",
633
- context: { carrier: { path: "/carrier" } },
1960
+ name: "remove",
1961
+ context: { qty: { path: "qty" }, sku: { path: "sku" } },
634
1962
  },
635
1963
  },
636
1964
  },
637
- { id: "submit-text", component: "Text", text: { path: "/label" } },
638
1965
  {
639
- id: "close",
1966
+ id: "order",
640
1967
  component: "Button",
641
- child: "close-icon",
642
- action: { event: { name: "close" } },
1968
+ label: "Order",
1969
+ action: {
1970
+ event: { name: "order", context: { items: { path: "/items" } } },
1971
+ },
643
1972
  },
644
- { id: "close-icon", component: "Icon", name: "x" },
645
1973
  ],
646
- { carrier: ["dhl"], label: "Create label" },
1974
+ {
1975
+ items: [
1976
+ { sku: "a", qty: 1 },
1977
+ { sku: "b", qty: 2 },
1978
+ ],
1979
+ },
647
1980
  );
648
1981
 
1982
+ const line = (index: number, sku: string, qty: string) => ({
1983
+ $type: "ListViewItem",
1984
+ children: {
1985
+ $type: "Row",
1986
+ children: [
1987
+ {
1988
+ $type: "Input",
1989
+ label: "Quantity",
1990
+ name: `/items/${index}/qty`,
1991
+ defaultValue: qty,
1992
+ },
1993
+ {
1994
+ $type: "Button",
1995
+ label: "Remove",
1996
+ $action: {
1997
+ type: "a2ui:action",
1998
+ name: "remove",
1999
+ surfaceId: "",
2000
+ sourceComponentId: "remove",
2001
+ context: {
2002
+ qty: { $field: `/items/${index}/qty`, fallback: Number(qty) },
2003
+ sku,
2004
+ },
2005
+ },
2006
+ },
2007
+ ],
2008
+ },
2009
+ });
649
2010
  expect(convertSurfaceToUISpec(surface)).toEqual({
650
2011
  spec: {
651
- $type: "Card",
2012
+ $type: "Col",
652
2013
  children: [
653
2014
  {
654
- $type: "Col",
655
- children: [
656
- {
657
- $type: "Button",
658
- label: "Create label",
659
- $action: {
660
- type: "a2ui:action",
661
- name: "submit",
662
- surfaceId: "",
663
- sourceComponentId: "submit",
664
- context: { carrier: ["dhl"] },
665
- },
666
- },
667
- {
668
- $type: "Button",
669
- $action: {
670
- type: "a2ui:action",
671
- name: "close",
672
- surfaceId: "",
673
- sourceComponentId: "close",
674
- },
675
- children: [{ $type: "Icon", name: "x" }],
2015
+ $type: "ListView",
2016
+ children: [line(0, "a", "1"), line(1, "b", "2")],
2017
+ },
2018
+ {
2019
+ $type: "Button",
2020
+ label: "Order",
2021
+ $action: {
2022
+ type: "a2ui:action",
2023
+ name: "order",
2024
+ surfaceId: "",
2025
+ sourceComponentId: "order",
2026
+ context: {
2027
+ items: [
2028
+ { sku: "a", qty: { $field: "/items/0/qty", fallback: 1 } },
2029
+ { sku: "b", qty: { $field: "/items/1/qty", fallback: 2 } },
2030
+ ],
676
2031
  },
677
- ],
2032
+ },
678
2033
  },
679
2034
  ],
680
2035
  },
@@ -682,47 +2037,121 @@ describe("convertSurfaceToUISpec", () => {
682
2037
  });
683
2038
  });
684
2039
 
685
- it("takes a button label only from a Text child and keeps action data as bound", () => {
2040
+ it("grows a bound list for a field reference the way the data model grows", () => {
686
2041
  const surface = surfaceFrom(
687
2042
  [
688
2043
  {
689
2044
  id: "root",
690
2045
  component: "Column",
691
- children: ["custom-label", "bound-context"],
2046
+ children: ["far", "next", "row", "cleared", "send"],
692
2047
  },
693
- { id: "custom-label", component: "Button", child: "note" },
694
- { id: "note", component: "Markdown", value: "Read me", tone: "loud" },
695
2048
  {
696
- id: "bound-context",
2049
+ id: "far",
2050
+ component: "TextField",
2051
+ value: { path: "/items/999999999/name" },
2052
+ },
2053
+ {
2054
+ id: "next",
2055
+ component: "TextField",
2056
+ value: { path: "/items/0/name" },
2057
+ },
2058
+ { id: "row", component: "TextField", value: { path: "/rows/0/name" } },
2059
+ {
2060
+ id: "cleared",
2061
+ component: "TextField",
2062
+ value: { path: "/cleared/0/name" },
2063
+ },
2064
+ {
2065
+ id: "send",
697
2066
  component: "Button",
698
- label: "Go",
2067
+ label: "Send",
699
2068
  action: {
700
- event: { name: "go", context: { target: { path: "/target" } } },
2069
+ event: {
2070
+ name: "send",
2071
+ context: {
2072
+ items: { path: "/items" },
2073
+ rows: { path: "/rows" },
2074
+ cleared: { path: "/cleared" },
2075
+ },
2076
+ },
701
2077
  },
702
2078
  },
703
2079
  ],
704
- { target: { path: "/secret" }, secret: "hidden" },
2080
+ { items: [], cleared: null },
705
2081
  );
706
2082
 
707
- expect(
708
- convertSurfaceToUISpec(surface, { keepUnknownComponents: true }),
709
- ).toEqual({
2083
+ expect(convertSurfaceToUISpec(surface).spec?.["children"]).toContainEqual({
2084
+ $type: "Button",
2085
+ label: "Send",
2086
+ $action: {
2087
+ type: "a2ui:action",
2088
+ name: "send",
2089
+ surfaceId: "",
2090
+ sourceComponentId: "send",
2091
+ context: {
2092
+ items: [{ name: { $field: "/items/0/name" } }],
2093
+ rows: [{ name: { $field: "/rows/0/name" } }],
2094
+ cleared: [{ name: { $field: "/cleared/0/name" } }],
2095
+ },
2096
+ },
2097
+ });
2098
+ });
2099
+
2100
+ it("points function call arguments and paths the data model does not hold yet at their inputs", () => {
2101
+ const surface = surfaceFrom([
2102
+ { id: "root", component: "Column", children: ["link", "open", "save"] },
2103
+ {
2104
+ id: "link",
2105
+ component: "TextField",
2106
+ label: "Link",
2107
+ value: { path: "/draft/link" },
2108
+ },
2109
+ {
2110
+ id: "open",
2111
+ component: "Button",
2112
+ label: "Open",
2113
+ action: {
2114
+ functionCall: {
2115
+ call: "openUrl",
2116
+ args: { url: { path: "/draft/link" } },
2117
+ },
2118
+ },
2119
+ },
2120
+ {
2121
+ id: "save",
2122
+ component: "Button",
2123
+ label: "Save",
2124
+ action: {
2125
+ event: { name: "save", context: { draft: { path: "/draft" } } },
2126
+ },
2127
+ },
2128
+ ]);
2129
+
2130
+ expect(convertSurfaceToUISpec(surface)).toEqual({
710
2131
  spec: {
711
2132
  $type: "Col",
712
2133
  children: [
2134
+ { $type: "Input", label: "Link", name: "/draft/link" },
713
2135
  {
714
2136
  $type: "Button",
715
- children: [{ $type: "Markdown", value: "Read me", tone: "loud" }],
2137
+ label: "Open",
2138
+ $action: {
2139
+ type: "a2ui:functionCall",
2140
+ call: "openUrl",
2141
+ surfaceId: "",
2142
+ sourceComponentId: "open",
2143
+ args: { url: { $field: "/draft/link" } },
2144
+ },
716
2145
  },
717
2146
  {
718
2147
  $type: "Button",
719
- label: "Go",
2148
+ label: "Save",
720
2149
  $action: {
721
2150
  type: "a2ui:action",
722
- name: "go",
2151
+ name: "save",
723
2152
  surfaceId: "",
724
- sourceComponentId: "bound-context",
725
- context: { target: { path: "/secret" } },
2153
+ sourceComponentId: "save",
2154
+ context: { draft: { link: { $field: "/draft/link" } } },
726
2155
  },
727
2156
  },
728
2157
  ],
@@ -731,6 +2160,117 @@ describe("convertSurfaceToUISpec", () => {
731
2160
  });
732
2161
  });
733
2162
 
2163
+ it("maps button and text field variants", () => {
2164
+ const surface = surfaceFrom(
2165
+ [
2166
+ {
2167
+ id: "root",
2168
+ component: "Column",
2169
+ children: ["save", "skip", "plain", "styled", "notes", "code"],
2170
+ },
2171
+ { id: "save", component: "Button", label: "Save", variant: "primary" },
2172
+ {
2173
+ id: "skip",
2174
+ component: "Button",
2175
+ label: "Skip",
2176
+ variant: "borderless",
2177
+ },
2178
+ { id: "plain", component: "Button", label: "Back", variant: "default" },
2179
+ {
2180
+ id: "styled",
2181
+ component: "Button",
2182
+ label: "Delete",
2183
+ variant: "primary",
2184
+ buttonStyle: "danger",
2185
+ },
2186
+ {
2187
+ id: "notes",
2188
+ component: "TextField",
2189
+ label: "Notes",
2190
+ variant: "longText",
2191
+ value: { path: "/notes" },
2192
+ },
2193
+ {
2194
+ id: "code",
2195
+ component: "TextField",
2196
+ label: "Code",
2197
+ variant: "shortText",
2198
+ value: { path: "/code" },
2199
+ },
2200
+ ],
2201
+ { notes: "", code: "" },
2202
+ );
2203
+
2204
+ expect(convertSurfaceToUISpec(surface)).toEqual({
2205
+ spec: {
2206
+ $type: "Col",
2207
+ children: [
2208
+ { $type: "Button", label: "Save", buttonStyle: "primary" },
2209
+ { $type: "Button", label: "Skip", buttonStyle: "ghost" },
2210
+ { $type: "Button", label: "Back" },
2211
+ { $type: "Button", label: "Delete", buttonStyle: "danger" },
2212
+ {
2213
+ $type: "Input",
2214
+ multiline: true,
2215
+ label: "Notes",
2216
+ name: "/notes",
2217
+ defaultValue: "",
2218
+ },
2219
+ { $type: "Input", label: "Code", name: "/code", defaultValue: "" },
2220
+ ],
2221
+ },
2222
+ warnings: [],
2223
+ });
2224
+ });
2225
+
2226
+ it("maps function call actions and warns about an action it cannot read", () => {
2227
+ const surface = surfaceFrom(
2228
+ [
2229
+ { id: "root", component: "Column", children: ["docs", "broken"] },
2230
+ {
2231
+ id: "docs",
2232
+ component: "Button",
2233
+ child: "docs-text",
2234
+ action: {
2235
+ functionCall: {
2236
+ call: "openUrl",
2237
+ args: { url: { path: "/docsUrl" } },
2238
+ },
2239
+ },
2240
+ },
2241
+ { id: "docs-text", component: "Text", text: "Docs" },
2242
+ {
2243
+ id: "broken",
2244
+ component: "Button",
2245
+ label: "Broken",
2246
+ action: { functionCall: { args: { url: "https://a2ui.org" } } },
2247
+ },
2248
+ ],
2249
+ { docsUrl: "https://a2ui.org" },
2250
+ );
2251
+
2252
+ expect(convertSurfaceToUISpec(surface)).toEqual({
2253
+ spec: {
2254
+ $type: "Col",
2255
+ children: [
2256
+ {
2257
+ $type: "Button",
2258
+ label: "Docs",
2259
+ $action: {
2260
+ type: "a2ui:functionCall",
2261
+ call: "openUrl",
2262
+ surfaceId: "",
2263
+ sourceComponentId: "docs",
2264
+ args: { url: "https://a2ui.org" },
2265
+ },
2266
+ },
2267
+ { $type: "Button", label: "Broken" },
2268
+ ],
2269
+ },
2270
+ warnings: ['Component "broken" has a malformed action.'],
2271
+ });
2272
+ });
2273
+
734
2274
  it("warns about a child reference it cannot follow", () => {
735
2275
  const surface = surfaceFrom([
736
2276
  { id: "root", component: "Column", children: ["missing", "malformed"] },
@@ -998,11 +2538,9 @@ describe("convertSurfaceToUISpec", () => {
998
2538
  $action: { type: "injected" },
999
2539
  $key: "injected",
1000
2540
  $status: "injected",
1001
- children: {
1002
- template: { componentId: "item", path: "/items" },
1003
- },
2541
+ children: { componentId: "item", path: "/items" },
1004
2542
  },
1005
- { id: "item", component: "Text", text: { path: "/label" } },
2543
+ { id: "item", component: "Text", text: { path: "label" } },
1006
2544
  ],
1007
2545
  { items: [{ label: "One" }] },
1008
2546
  );
@@ -1031,11 +2569,9 @@ describe("convertSurfaceToUISpec", () => {
1031
2569
  {
1032
2570
  id: "root",
1033
2571
  component: "CustomList",
1034
- children: {
1035
- template: { componentId: "item", path: "/items" },
1036
- },
2572
+ children: { componentId: "item", path: "/items" },
1037
2573
  },
1038
- { id: "item", component: "Text", text: { path: "/label" } },
2574
+ { id: "item", component: "Text", text: { path: "label" } },
1039
2575
  ],
1040
2576
  { items: [{ label: "One" }] },
1041
2577
  );
@@ -1061,11 +2597,9 @@ describe("convertSurfaceToUISpec", () => {
1061
2597
  id: "root",
1062
2598
  component: "CustomList",
1063
2599
  title: { path: "/title" },
1064
- children: {
1065
- template: { componentId: "item", path: "/items" },
1066
- },
2600
+ children: { componentId: "item", path: "/items" },
1067
2601
  },
1068
- { id: "item", component: "Text", text: { path: "/label" } },
2602
+ { id: "item", component: "Text", text: { path: "label" } },
1069
2603
  ],
1070
2604
  { title: "Tasks", items: [{ label: "One" }, { label: "Two" }] },
1071
2605
  );
@@ -1096,3 +2630,85 @@ describe("convertSurfaceToUISpec", () => {
1096
2630
  });
1097
2631
  });
1098
2632
  });
2633
+
2634
+ it("keeps live controls internal and action-free while plain conversion stays uncontrolled", () => {
2635
+ const surface = applyA2uiOperations(new Map(), [
2636
+ {
2637
+ version: "v1.0",
2638
+ createSurface: {
2639
+ surfaceId: "main",
2640
+ components: [
2641
+ {
2642
+ id: "root",
2643
+ component: "Column",
2644
+ children: ["field", "pick", "slider"],
2645
+ },
2646
+ { id: "field", component: "TextField", text: { path: "/name" } },
2647
+ {
2648
+ id: "pick",
2649
+ component: "ChoicePicker",
2650
+ value: { path: "/choices" },
2651
+ options: [{ label: "A", value: "a" }],
2652
+ },
2653
+ {
2654
+ id: "slider",
2655
+ component: "Slider",
2656
+ value: { path: "/count" },
2657
+ min: 0,
2658
+ max: 10,
2659
+ },
2660
+ ],
2661
+ dataModel: { name: "Ada", choices: ["a"], count: 2 },
2662
+ },
2663
+ },
2664
+ ]).state.get("main")!;
2665
+ expect(convertSurfaceToUISpec(surface).spec).toMatchObject({
2666
+ children: [
2667
+ { $type: "Input", defaultValue: "Ada" },
2668
+ { $type: "RadioGroup", defaultValue: "a" },
2669
+ { $type: "Slider", defaultValue: 2 },
2670
+ ],
2671
+ });
2672
+ const convert = createLiveSurfaceConverter(surface);
2673
+ const live = convert(surface.dataModel);
2674
+ expect(live.spec).toEqual({
2675
+ $type: "Col",
2676
+ children: [
2677
+ {
2678
+ $type: "Input",
2679
+ value: "Ada",
2680
+ name: "/name",
2681
+ },
2682
+ {
2683
+ $type: "RadioGroup",
2684
+ value: "a",
2685
+ name: "/choices",
2686
+ options: [{ label: "A", value: "a" }],
2687
+ },
2688
+ {
2689
+ $type: "Slider",
2690
+ value: 2,
2691
+ name: "/count",
2692
+ min: 0,
2693
+ max: 10,
2694
+ step: 0.1,
2695
+ },
2696
+ ],
2697
+ });
2698
+ expect(live.bindings).toEqual(
2699
+ new Map([
2700
+ ["/name", { value: "Ada", arrayValue: false }],
2701
+ ["/choices", { value: "a", arrayValue: true }],
2702
+ ["/count", { value: 2, arrayValue: false }],
2703
+ ]),
2704
+ );
2705
+ const updated = convert({ name: "Grace", choices: ["a"], count: 2 });
2706
+ expect((updated.spec!.children as UIElement[])[0]).toEqual({
2707
+ $type: "Input",
2708
+ name: "/name",
2709
+ value: "Grace",
2710
+ });
2711
+ expect((updated.spec!.children as UIElement[])[2]).toBe(
2712
+ (live.spec!.children as UIElement[])[2],
2713
+ );
2714
+ });