wyreframe 0.1.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 (117) hide show
  1. package/README.md +123 -0
  2. package/dist/index.d.ts +267 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +195 -0
  5. package/dist/index.js.map +1 -0
  6. package/package.json +63 -0
  7. package/src/parser/Core/Bounds.mjs +61 -0
  8. package/src/parser/Core/Bounds.res +65 -0
  9. package/src/parser/Core/Grid.mjs +268 -0
  10. package/src/parser/Core/Grid.res +265 -0
  11. package/src/parser/Core/Position.mjs +83 -0
  12. package/src/parser/Core/Position.res +54 -0
  13. package/src/parser/Core/Types.mjs +435 -0
  14. package/src/parser/Core/Types.res +331 -0
  15. package/src/parser/Core/__tests__/Bounds_test.mjs +326 -0
  16. package/src/parser/Core/__tests__/Bounds_test.res +412 -0
  17. package/src/parser/Core/__tests__/Grid_test.mjs +322 -0
  18. package/src/parser/Core/__tests__/Grid_test.res +319 -0
  19. package/src/parser/Core/__tests__/Types_test.mjs +614 -0
  20. package/src/parser/Core/__tests__/Types_test.res +650 -0
  21. package/src/parser/Detector/BoxTracer.mjs +302 -0
  22. package/src/parser/Detector/BoxTracer.res +374 -0
  23. package/src/parser/Detector/HierarchyBuilder.mjs +158 -0
  24. package/src/parser/Detector/HierarchyBuilder.res +315 -0
  25. package/src/parser/Detector/ShapeDetector.mjs +134 -0
  26. package/src/parser/Detector/ShapeDetector.res +236 -0
  27. package/src/parser/Detector/__tests__/BoxTracer_test.mjs +70 -0
  28. package/src/parser/Detector/__tests__/BoxTracer_test.res +92 -0
  29. package/src/parser/Detector/__tests__/HierarchyBuilder_test.mjs +489 -0
  30. package/src/parser/Detector/__tests__/HierarchyBuilder_test.res +849 -0
  31. package/src/parser/Detector/__tests__/ShapeDetector_test.mjs +377 -0
  32. package/src/parser/Detector/__tests__/ShapeDetector_test.res +563 -0
  33. package/src/parser/Errors/ErrorContext.mjs +106 -0
  34. package/src/parser/Errors/ErrorContext.res +191 -0
  35. package/src/parser/Errors/ErrorMessages.mjs +289 -0
  36. package/src/parser/Errors/ErrorMessages.res +303 -0
  37. package/src/parser/Errors/ErrorTypes.mjs +105 -0
  38. package/src/parser/Errors/ErrorTypes.res +169 -0
  39. package/src/parser/Interactions/InteractionMerger.mjs +266 -0
  40. package/src/parser/Interactions/InteractionMerger.res +450 -0
  41. package/src/parser/Interactions/InteractionParser.mjs +88 -0
  42. package/src/parser/Interactions/InteractionParser.res +127 -0
  43. package/src/parser/Interactions/SimpleInteractionParser.mjs +278 -0
  44. package/src/parser/Interactions/SimpleInteractionParser.res +262 -0
  45. package/src/parser/Interactions/__tests__/InteractionMerger_test.mjs +576 -0
  46. package/src/parser/Interactions/__tests__/InteractionMerger_test.res +646 -0
  47. package/src/parser/Parser.gen.tsx +96 -0
  48. package/src/parser/Parser.mjs +212 -0
  49. package/src/parser/Parser.res +481 -0
  50. package/src/parser/Scanner/__tests__/Grid_manual.mjs +214 -0
  51. package/src/parser/Scanner/__tests__/Grid_manual.res +141 -0
  52. package/src/parser/Semantic/ASTBuilder.mjs +197 -0
  53. package/src/parser/Semantic/ASTBuilder.res +288 -0
  54. package/src/parser/Semantic/AlignmentCalc.mjs +41 -0
  55. package/src/parser/Semantic/AlignmentCalc.res +104 -0
  56. package/src/parser/Semantic/Elements/ButtonParser.mjs +58 -0
  57. package/src/parser/Semantic/Elements/ButtonParser.res +131 -0
  58. package/src/parser/Semantic/Elements/CheckboxParser.mjs +58 -0
  59. package/src/parser/Semantic/Elements/CheckboxParser.res +79 -0
  60. package/src/parser/Semantic/Elements/CodeTextParser.mjs +50 -0
  61. package/src/parser/Semantic/Elements/CodeTextParser.res +111 -0
  62. package/src/parser/Semantic/Elements/ElementParser.mjs +15 -0
  63. package/src/parser/Semantic/Elements/ElementParser.res +83 -0
  64. package/src/parser/Semantic/Elements/EmphasisParser.mjs +46 -0
  65. package/src/parser/Semantic/Elements/EmphasisParser.res +67 -0
  66. package/src/parser/Semantic/Elements/InputParser.mjs +41 -0
  67. package/src/parser/Semantic/Elements/InputParser.res +97 -0
  68. package/src/parser/Semantic/Elements/LinkParser.mjs +60 -0
  69. package/src/parser/Semantic/Elements/LinkParser.res +156 -0
  70. package/src/parser/Semantic/Elements/TextParser.mjs +19 -0
  71. package/src/parser/Semantic/Elements/TextParser.res +42 -0
  72. package/src/parser/Semantic/Elements/__tests__/ButtonParser_test.mjs +189 -0
  73. package/src/parser/Semantic/Elements/__tests__/ButtonParser_test.res +257 -0
  74. package/src/parser/Semantic/Elements/__tests__/CheckboxParser_test.mjs +202 -0
  75. package/src/parser/Semantic/Elements/__tests__/CheckboxParser_test.res +250 -0
  76. package/src/parser/Semantic/Elements/__tests__/CodeTextParser_manual.mjs +293 -0
  77. package/src/parser/Semantic/Elements/__tests__/CodeTextParser_manual.res +134 -0
  78. package/src/parser/Semantic/Elements/__tests__/InputParser_test.mjs +253 -0
  79. package/src/parser/Semantic/Elements/__tests__/InputParser_test.res +304 -0
  80. package/src/parser/Semantic/Elements/__tests__/LinkParser_test.mjs +289 -0
  81. package/src/parser/Semantic/Elements/__tests__/LinkParser_test.res +402 -0
  82. package/src/parser/Semantic/Elements/__tests__/TextParser_test.mjs +149 -0
  83. package/src/parser/Semantic/Elements/__tests__/TextParser_test.res +167 -0
  84. package/src/parser/Semantic/ParserRegistry.mjs +82 -0
  85. package/src/parser/Semantic/ParserRegistry.res +145 -0
  86. package/src/parser/Semantic/SemanticParser.mjs +850 -0
  87. package/src/parser/Semantic/SemanticParser.res +1368 -0
  88. package/src/parser/Semantic/__tests__/ASTBuilder_test.mjs +187 -0
  89. package/src/parser/Semantic/__tests__/ASTBuilder_test.res +192 -0
  90. package/src/parser/Semantic/__tests__/ParserRegistry_test.mjs +154 -0
  91. package/src/parser/Semantic/__tests__/ParserRegistry_test.res +191 -0
  92. package/src/parser/Semantic/__tests__/SemanticParser_integration_test.mjs +768 -0
  93. package/src/parser/Semantic/__tests__/SemanticParser_integration_test.res +1069 -0
  94. package/src/parser/Semantic/__tests__/SemanticParser_manual.mjs +1329 -0
  95. package/src/parser/Semantic/__tests__/SemanticParser_manual.res +544 -0
  96. package/src/parser/TestMain.mjs +21 -0
  97. package/src/parser/TestMain.res +14 -0
  98. package/src/parser/TextExtractor.mjs +179 -0
  99. package/src/parser/TextExtractor.res +264 -0
  100. package/src/parser/__tests__/GridScanner_integration.test.mjs +632 -0
  101. package/src/parser/__tests__/GridScanner_integration.test.res +816 -0
  102. package/src/parser/__tests__/Performance.test.mjs +244 -0
  103. package/src/parser/__tests__/Performance.test.res +371 -0
  104. package/src/parser/__tests__/PerformanceFixtures.mjs +200 -0
  105. package/src/parser/__tests__/PerformanceFixtures.res +284 -0
  106. package/src/parser/__tests__/WyreframeParser_integration.test.mjs +770 -0
  107. package/src/parser/__tests__/WyreframeParser_integration.test.res +1008 -0
  108. package/src/parser/__tests__/fixtures/alignment-test.txt +9 -0
  109. package/src/parser/__tests__/fixtures/all-elements.txt +16 -0
  110. package/src/parser/__tests__/fixtures/login-scene.txt +17 -0
  111. package/src/parser/__tests__/fixtures/multi-scene.txt +25 -0
  112. package/src/parser/__tests__/fixtures/nested-boxes.txt +15 -0
  113. package/src/parser/__tests__/fixtures/simple-box.txt +5 -0
  114. package/src/parser/__tests__/fixtures/with-dividers.txt +14 -0
  115. package/src/renderer/Renderer.gen.tsx +32 -0
  116. package/src/renderer/Renderer.mjs +391 -0
  117. package/src/renderer/Renderer.res +558 -0
@@ -0,0 +1,266 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Js_dict from "@rescript/runtime/lib/es6/Js_dict.js";
4
+ import * as Js_json from "@rescript/runtime/lib/es6/Js_json.js";
5
+ import * as Core__Array from "@rescript/core/src/Core__Array.mjs";
6
+ import * as Belt_MapString from "@rescript/runtime/lib/es6/Belt_MapString.js";
7
+ import * as Belt_SetString from "@rescript/runtime/lib/es6/Belt_SetString.js";
8
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
9
+
10
+ function collectElementIds(element) {
11
+ switch (element.TAG) {
12
+ case "Box" :
13
+ return Core__Array.reduce(element.children, undefined, (acc, child) => Belt_SetString.union(acc, collectElementIds(child)));
14
+ case "Button" :
15
+ case "Input" :
16
+ case "Link" :
17
+ return Belt_SetString.add(undefined, element.id);
18
+ case "Row" :
19
+ return Core__Array.reduce(element.children, undefined, (acc, child) => Belt_SetString.union(acc, collectElementIds(child)));
20
+ case "Section" :
21
+ let withSection = Belt_SetString.add(undefined, element.name);
22
+ return Core__Array.reduce(element.children, withSection, (acc, child) => Belt_SetString.union(acc, collectElementIds(child)));
23
+ default:
24
+ return;
25
+ }
26
+ }
27
+
28
+ function collectSceneElementIds(scene) {
29
+ return Core__Array.reduce(scene.elements, undefined, (acc, element) => Belt_SetString.union(acc, collectElementIds(element)));
30
+ }
31
+
32
+ function buildSceneElementMap(ast) {
33
+ return Core__Array.reduce(ast.scenes, undefined, (acc, scene) => {
34
+ let elementIds = collectSceneElementIds(scene);
35
+ return Belt_MapString.set(acc, scene.id, elementIds);
36
+ });
37
+ }
38
+
39
+ function validateInteractions(sceneInteractionsList, sceneElementMap) {
40
+ let errors = [];
41
+ sceneInteractionsList.forEach(sceneInteractions => {
42
+ let sceneId = sceneInteractions.sceneId;
43
+ let elementIds = Belt_MapString.get(sceneElementMap, sceneId);
44
+ if (elementIds !== undefined) {
45
+ let elementIds$1 = Primitive_option.valFromOption(elementIds);
46
+ let seenElements = {
47
+ contents: undefined
48
+ };
49
+ sceneInteractions.interactions.forEach(interaction => {
50
+ let elementId = interaction.elementId;
51
+ if (Belt_SetString.has(seenElements.contents, elementId)) {
52
+ errors.push({
53
+ TAG: "DuplicateInteraction",
54
+ sceneId: sceneId,
55
+ elementId: elementId
56
+ });
57
+ } else {
58
+ seenElements.contents = Belt_SetString.add(seenElements.contents, elementId);
59
+ }
60
+ if (!Belt_SetString.has(elementIds$1, elementId)) {
61
+ errors.push({
62
+ TAG: "ElementNotFound",
63
+ sceneId: sceneId,
64
+ elementId: elementId,
65
+ position: undefined
66
+ });
67
+ return;
68
+ }
69
+ });
70
+ return;
71
+ }
72
+ errors.push({
73
+ TAG: "SceneNotFound",
74
+ sceneId: sceneId
75
+ });
76
+ });
77
+ return errors;
78
+ }
79
+
80
+ function findInteractionForElement(elementId, sceneInteractions) {
81
+ if (sceneInteractions !== undefined) {
82
+ return sceneInteractions.interactions.find(interaction => interaction.elementId === elementId);
83
+ }
84
+ }
85
+
86
+ function hasInputType(interaction) {
87
+ if (interaction === undefined) {
88
+ return false;
89
+ }
90
+ let json = Js_dict.get(interaction.properties, "type");
91
+ if (json === undefined) {
92
+ return false;
93
+ }
94
+ let str = Js_json.decodeString(json);
95
+ return str === "input";
96
+ }
97
+
98
+ function attachInteractionsToElement(element, sceneInteractions) {
99
+ switch (element.TAG) {
100
+ case "Box" :
101
+ let bounds = element.bounds;
102
+ let name = element.name;
103
+ let enhancedChildren = element.children.map(child => attachInteractionsToElement(child, sceneInteractions));
104
+ let isAnonymous = name === undefined;
105
+ let hasOnlyInputs = enhancedChildren.length > 0 && enhancedChildren.every(child => child.TAG === "Input");
106
+ let shouldUnwrap = isAnonymous && hasOnlyInputs && enhancedChildren.some(child => {
107
+ if (child.TAG === "Input") {
108
+ return hasInputType(findInteractionForElement(child.id, sceneInteractions));
109
+ } else {
110
+ return false;
111
+ }
112
+ });
113
+ if (!shouldUnwrap) {
114
+ return {
115
+ TAG: "Box",
116
+ name: name,
117
+ bounds: bounds,
118
+ children: enhancedChildren
119
+ };
120
+ }
121
+ let input = enhancedChildren[0];
122
+ if (input !== undefined) {
123
+ return input;
124
+ } else {
125
+ return {
126
+ TAG: "Box",
127
+ name: name,
128
+ bounds: bounds,
129
+ children: enhancedChildren
130
+ };
131
+ }
132
+ case "Button" :
133
+ let id = element.id;
134
+ let interaction = findInteractionForElement(id, sceneInteractions);
135
+ let actions = interaction !== undefined ? interaction.actions : [];
136
+ return {
137
+ TAG: "Button",
138
+ id: id,
139
+ text: element.text,
140
+ position: element.position,
141
+ align: element.align,
142
+ actions: actions
143
+ };
144
+ case "Input" :
145
+ let placeholder = element.placeholder;
146
+ let id$1 = element.id;
147
+ let interaction$1 = findInteractionForElement(id$1, sceneInteractions);
148
+ let enhancedPlaceholder;
149
+ if (interaction$1 !== undefined) {
150
+ let json = Js_dict.get(interaction$1.properties, "placeholder");
151
+ if (json !== undefined) {
152
+ let str = Js_json.decodeString(json);
153
+ enhancedPlaceholder = str !== undefined ? str : placeholder;
154
+ } else {
155
+ enhancedPlaceholder = placeholder;
156
+ }
157
+ } else {
158
+ enhancedPlaceholder = placeholder;
159
+ }
160
+ return {
161
+ TAG: "Input",
162
+ id: id$1,
163
+ placeholder: enhancedPlaceholder,
164
+ position: element.position
165
+ };
166
+ case "Link" :
167
+ let id$2 = element.id;
168
+ let interaction$2 = findInteractionForElement(id$2, sceneInteractions);
169
+ let actions$1 = interaction$2 !== undefined ? interaction$2.actions : [];
170
+ return {
171
+ TAG: "Link",
172
+ id: id$2,
173
+ text: element.text,
174
+ position: element.position,
175
+ align: element.align,
176
+ actions: actions$1
177
+ };
178
+ case "Row" :
179
+ let enhancedChildren$1 = element.children.map(child => attachInteractionsToElement(child, sceneInteractions));
180
+ return {
181
+ TAG: "Row",
182
+ children: enhancedChildren$1,
183
+ align: element.align
184
+ };
185
+ case "Section" :
186
+ let name$1 = element.name;
187
+ findInteractionForElement(name$1, sceneInteractions);
188
+ let enhancedChildren$2 = element.children.map(child => attachInteractionsToElement(child, sceneInteractions));
189
+ return {
190
+ TAG: "Section",
191
+ name: name$1,
192
+ children: enhancedChildren$2
193
+ };
194
+ default:
195
+ return element;
196
+ }
197
+ }
198
+
199
+ function attachInteractionsToScene(scene, sceneInteractionsList) {
200
+ let sceneInteractions = sceneInteractionsList.find(si => si.sceneId === scene.id);
201
+ let enhancedElements = scene.elements.map(element => attachInteractionsToElement(element, sceneInteractions));
202
+ return {
203
+ id: scene.id,
204
+ title: scene.title,
205
+ transition: scene.transition,
206
+ device: scene.device,
207
+ elements: enhancedElements
208
+ };
209
+ }
210
+
211
+ function mergeInteractions(ast, sceneInteractionsList) {
212
+ let sceneElementMap = buildSceneElementMap(ast);
213
+ let validationErrors = validateInteractions(sceneInteractionsList, sceneElementMap);
214
+ let hardErrors = validationErrors.filter(error => {
215
+ switch (error.TAG) {
216
+ case "ElementNotFound" :
217
+ return false;
218
+ case "DuplicateInteraction" :
219
+ case "SceneNotFound" :
220
+ return true;
221
+ }
222
+ });
223
+ if (hardErrors.length > 0) {
224
+ return {
225
+ TAG: "Error",
226
+ _0: hardErrors
227
+ };
228
+ }
229
+ let enhancedScenes = ast.scenes.map(scene => attachInteractionsToScene(scene, sceneInteractionsList));
230
+ return {
231
+ TAG: "Ok",
232
+ _0: {
233
+ scenes: enhancedScenes
234
+ }
235
+ };
236
+ }
237
+
238
+ function formatError(error) {
239
+ switch (error.TAG) {
240
+ case "ElementNotFound" :
241
+ return `Element "` + error.elementId + `" not found in scene "` + error.sceneId + `"`;
242
+ case "DuplicateInteraction" :
243
+ return `Duplicate interaction for element "` + error.elementId + `" in scene "` + error.sceneId + `"`;
244
+ case "SceneNotFound" :
245
+ return `Scene "` + error.sceneId + `" not found in wireframe`;
246
+ }
247
+ }
248
+
249
+ function formatErrors(errors) {
250
+ return errors.map(formatError).join("\n");
251
+ }
252
+
253
+ export {
254
+ collectElementIds,
255
+ collectSceneElementIds,
256
+ buildSceneElementMap,
257
+ validateInteractions,
258
+ findInteractionForElement,
259
+ hasInputType,
260
+ attachInteractionsToElement,
261
+ attachInteractionsToScene,
262
+ mergeInteractions,
263
+ formatError,
264
+ formatErrors,
265
+ }
266
+ /* No side effect */
@@ -0,0 +1,450 @@
1
+ /**
2
+ * InteractionMerger.res
3
+ *
4
+ * Merges interaction definitions with wireframe AST elements.
5
+ * Validates that all element IDs referenced in interactions exist in the AST.
6
+ * Attaches properties and actions to the appropriate elements.
7
+ *
8
+ * Requirements: REQ-20 (Integration - Backward Compatibility)
9
+ */
10
+
11
+ open Types
12
+
13
+ // ============================================================================
14
+ // Error Types
15
+ // ============================================================================
16
+
17
+ /**
18
+ * Errors that can occur during interaction merging
19
+ */
20
+ type mergeError =
21
+ | ElementNotFound({
22
+ sceneId: string,
23
+ elementId: string,
24
+ position: option<string>, // Optional position info from interaction
25
+ })
26
+ | DuplicateInteraction({
27
+ sceneId: string,
28
+ elementId: string,
29
+ })
30
+ | SceneNotFound({sceneId: string})
31
+
32
+ /**
33
+ * Result type for merge operation
34
+ */
35
+ type mergeResult = result<ast, array<mergeError>>
36
+
37
+ // ============================================================================
38
+ // Element ID Collection
39
+ // ============================================================================
40
+
41
+ /**
42
+ * Recursively collect all element IDs from an element and its children.
43
+ * Returns a set of element IDs found in the element tree.
44
+ *
45
+ * @param element - The element to collect IDs from
46
+ * @returns Set of element IDs
47
+ */
48
+ let rec collectElementIds = (element: element): Belt.Set.String.t => {
49
+ let ids = Belt.Set.String.empty
50
+
51
+ switch element {
52
+ | Box({children}) => {
53
+ // Recursively collect IDs from all children
54
+ children->Array.reduce(ids, (acc, child) => {
55
+ Belt.Set.String.union(acc, collectElementIds(child))
56
+ })
57
+ }
58
+ | Button({id}) => ids->Belt.Set.String.add(id)
59
+ | Input({id}) => ids->Belt.Set.String.add(id)
60
+ | Link({id}) => ids->Belt.Set.String.add(id)
61
+ | Checkbox(_) => ids // Checkboxes don't have explicit IDs
62
+ | Text(_) => ids // Text elements don't have explicit IDs
63
+ | Divider(_) => ids // Dividers don't have IDs
64
+ | Row({children}) => {
65
+ children->Array.reduce(ids, (acc, child) => {
66
+ Belt.Set.String.union(acc, collectElementIds(child))
67
+ })
68
+ }
69
+ | Section({children, name}) => {
70
+ // Add section name as ID
71
+ let withSection = ids->Belt.Set.String.add(name)
72
+ children->Array.reduce(withSection, (acc, child) => {
73
+ Belt.Set.String.union(acc, collectElementIds(child))
74
+ })
75
+ }
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Collect all element IDs from a scene's elements.
81
+ *
82
+ * @param scene - The scene to collect IDs from
83
+ * @returns Set of element IDs in the scene
84
+ */
85
+ let collectSceneElementIds = (scene: scene): Belt.Set.String.t => {
86
+ scene.elements->Array.reduce(Belt.Set.String.empty, (acc, element) => {
87
+ Belt.Set.String.union(acc, collectElementIds(element))
88
+ })
89
+ }
90
+
91
+ /**
92
+ * Build a map of scene IDs to their element ID sets.
93
+ *
94
+ * @param ast - The AST to analyze
95
+ * @returns Map of sceneId -> Set of element IDs
96
+ */
97
+ let buildSceneElementMap = (ast: ast): Belt.Map.String.t<Belt.Set.String.t> => {
98
+ ast.scenes->Array.reduce(Belt.Map.String.empty, (acc, scene) => {
99
+ let elementIds = collectSceneElementIds(scene)
100
+ acc->Belt.Map.String.set(scene.id, elementIds)
101
+ })
102
+ }
103
+
104
+ // ============================================================================
105
+ // Interaction Validation
106
+ // ============================================================================
107
+
108
+ /**
109
+ * Validate that all element IDs in interactions exist in the AST.
110
+ *
111
+ * @param sceneInteractions - Interactions to validate
112
+ * @param sceneElementMap - Map of scene IDs to element IDs
113
+ * @returns Array of validation errors (empty if all valid)
114
+ */
115
+ let validateInteractions = (
116
+ sceneInteractionsList: array<sceneInteractions>,
117
+ sceneElementMap: Belt.Map.String.t<Belt.Set.String.t>,
118
+ ): array<mergeError> => {
119
+ let errors = []
120
+
121
+ sceneInteractionsList->Array.forEach(sceneInteractions => {
122
+ let sceneId = sceneInteractions.sceneId
123
+
124
+ // Check if scene exists
125
+ switch sceneElementMap->Belt.Map.String.get(sceneId) {
126
+ | None => {
127
+ errors->Array.push(SceneNotFound({sceneId: sceneId}))->ignore
128
+ }
129
+ | Some(elementIds) => {
130
+ // Track seen element IDs to detect duplicates
131
+ let seenElements = ref(Belt.Set.String.empty)
132
+
133
+ // Validate each interaction
134
+ sceneInteractions.interactions->Array.forEach(interaction => {
135
+ let elementId = interaction.elementId
136
+
137
+ // Check for duplicate interaction
138
+ if seenElements.contents->Belt.Set.String.has(elementId) {
139
+ errors
140
+ ->Array.push(
141
+ DuplicateInteraction({
142
+ sceneId: sceneId,
143
+ elementId: elementId,
144
+ }),
145
+ )
146
+ ->ignore
147
+ } else {
148
+ seenElements := seenElements.contents->Belt.Set.String.add(elementId)
149
+ }
150
+
151
+ // Check if element exists in scene
152
+ if !(elementIds->Belt.Set.String.has(elementId)) {
153
+ errors
154
+ ->Array.push(
155
+ ElementNotFound({
156
+ sceneId: sceneId,
157
+ elementId: elementId,
158
+ position: None,
159
+ }),
160
+ )
161
+ ->ignore
162
+ }
163
+ })
164
+ }
165
+ }
166
+ })
167
+
168
+ errors
169
+ }
170
+
171
+ // ============================================================================
172
+ // Element Enhancement
173
+ // ============================================================================
174
+
175
+ /**
176
+ * Enhanced element type that includes interaction data.
177
+ * This is used internally during merging but converted back to
178
+ * the standard element type for the final AST.
179
+ */
180
+ type elementWithInteraction = {
181
+ element: element,
182
+ interaction: option<interaction>,
183
+ }
184
+
185
+ /**
186
+ * Find interaction for a given element ID in a scene's interactions.
187
+ *
188
+ * @param elementId - The element ID to find interaction for
189
+ * @param sceneInteractions - Scene interactions to search
190
+ * @returns Option containing the interaction if found
191
+ */
192
+ let findInteractionForElement = (
193
+ elementId: string,
194
+ sceneInteractions: option<sceneInteractions>,
195
+ ): option<interaction> => {
196
+ switch sceneInteractions {
197
+ | None => None
198
+ | Some(si) => {
199
+ si.interactions->Array.find(interaction => interaction.elementId === elementId)
200
+ }
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Helper to check if an interaction has type: "input" property.
206
+ */
207
+ let hasInputType = (interaction: option<interaction>): bool => {
208
+ switch interaction {
209
+ | Some({properties}) => {
210
+ switch properties->Js.Dict.get("type") {
211
+ | Some(json) => {
212
+ switch Js.Json.decodeString(json) {
213
+ | Some(str) => str === "input"
214
+ | None => false
215
+ }
216
+ }
217
+ | None => false
218
+ }
219
+ }
220
+ | None => false
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Recursively attach interactions to elements.
226
+ * This creates a new element tree with interaction data attached.
227
+ *
228
+ * Key feature: If an element has type: "input" in its interaction properties,
229
+ * parent Box elements containing only that Input will be unwrapped to render
230
+ * as the Input directly.
231
+ *
232
+ * @param element - The element to process
233
+ * @param sceneInteractions - Interactions for the current scene
234
+ * @returns The processed element (may be unwrapped if type: input specified)
235
+ */
236
+ let rec attachInteractionsToElement = (
237
+ element: element,
238
+ sceneInteractions: option<sceneInteractions>,
239
+ ): element => {
240
+ switch element {
241
+ | Box({name, bounds, children}) => {
242
+ // Recursively process children first
243
+ let enhancedChildren = children->Array.map(child => {
244
+ attachInteractionsToElement(child, sceneInteractions)
245
+ })
246
+
247
+ // Check if this is an input-only box that should be unwrapped
248
+ // A box is unwrapped if:
249
+ // 1. It has no name (anonymous box)
250
+ // 2. It contains only Input elements
251
+ // 3. At least one Input has type: "input" in its interaction
252
+ let isAnonymous = name === None
253
+ let hasOnlyInputs =
254
+ enhancedChildren->Array.length > 0 &&
255
+ enhancedChildren->Array.every(child => {
256
+ switch child {
257
+ | Input(_) => true
258
+ | _ => false
259
+ }
260
+ })
261
+
262
+ let shouldUnwrap = isAnonymous && hasOnlyInputs && {
263
+ enhancedChildren->Array.some(child => {
264
+ switch child {
265
+ | Input({id, _}) => {
266
+ let interaction = findInteractionForElement(id, sceneInteractions)
267
+ hasInputType(interaction)
268
+ }
269
+ | _ => false
270
+ }
271
+ })
272
+ }
273
+
274
+ if shouldUnwrap {
275
+ // Return the first Input directly (unwrap the box)
276
+ switch enhancedChildren->Array.get(0) {
277
+ | Some(input) => input
278
+ | None => Box({name, bounds, children: enhancedChildren})
279
+ }
280
+ } else {
281
+ Box({name, bounds, children: enhancedChildren})
282
+ }
283
+ }
284
+ | Button({id, text, position, align}) => {
285
+ // Find interaction for this button (if any)
286
+ let interaction = findInteractionForElement(id, sceneInteractions)
287
+ // Extract actions from interaction
288
+ let actions = switch interaction {
289
+ | Some({actions}) => actions
290
+ | None => []
291
+ }
292
+ Button({id, text, position, align, actions})
293
+ }
294
+ | Input({id, placeholder, position}) => {
295
+ let interaction = findInteractionForElement(id, sceneInteractions)
296
+ // Extract placeholder from interaction properties if available
297
+ let enhancedPlaceholder = switch interaction {
298
+ | Some({properties}) => {
299
+ switch properties->Js.Dict.get("placeholder") {
300
+ | Some(json) => {
301
+ switch Js.Json.decodeString(json) {
302
+ | Some(str) => Some(str)
303
+ | None => placeholder
304
+ }
305
+ }
306
+ | None => placeholder
307
+ }
308
+ }
309
+ | None => placeholder
310
+ }
311
+ Input({id, placeholder: enhancedPlaceholder, position})
312
+ }
313
+ | Link({id, text, position, align}) => {
314
+ // Find interaction for this link (if any)
315
+ let interaction = findInteractionForElement(id, sceneInteractions)
316
+ // Extract actions from interaction
317
+ let actions = switch interaction {
318
+ | Some({actions}) => actions
319
+ | None => []
320
+ }
321
+ Link({id, text, position, align, actions})
322
+ }
323
+ | Row({children, align}) => {
324
+ let enhancedChildren = children->Array.map(child => {
325
+ attachInteractionsToElement(child, sceneInteractions)
326
+ })
327
+ Row({children: enhancedChildren, align})
328
+ }
329
+ | Section({name, children}) => {
330
+ let _interaction = findInteractionForElement(name, sceneInteractions)
331
+ let enhancedChildren = children->Array.map(child => {
332
+ attachInteractionsToElement(child, sceneInteractions)
333
+ })
334
+ Section({name, children: enhancedChildren})
335
+ }
336
+ // Elements without IDs just pass through
337
+ | Checkbox(_) as el => el
338
+ | Text(_) as el => el
339
+ | Divider(_) as el => el
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Attach interactions to all elements in a scene.
345
+ *
346
+ * @param scene - The scene to process
347
+ * @param sceneInteractionsList - All scene interactions
348
+ * @returns Enhanced scene with interactions attached
349
+ */
350
+ let attachInteractionsToScene = (
351
+ scene: scene,
352
+ sceneInteractionsList: array<sceneInteractions>,
353
+ ): scene => {
354
+ // Find interactions for this scene
355
+ let sceneInteractions = sceneInteractionsList->Array.find(si => si.sceneId === scene.id)
356
+
357
+ // Process all elements
358
+ let enhancedElements = scene.elements->Array.map(element => {
359
+ attachInteractionsToElement(element, sceneInteractions)
360
+ })
361
+
362
+ {
363
+ id: scene.id,
364
+ title: scene.title,
365
+ transition: scene.transition,
366
+ device: scene.device,
367
+ elements: enhancedElements,
368
+ }
369
+ }
370
+
371
+ // ============================================================================
372
+ // Public API
373
+ // ============================================================================
374
+
375
+ /**
376
+ * Merge interactions into the AST.
377
+ * Validates that all element IDs exist and attaches interaction data to elements.
378
+ *
379
+ * Lenient mode: ElementNotFound errors are treated as warnings and skipped.
380
+ * Only SceneNotFound and DuplicateInteraction are hard errors.
381
+ *
382
+ * @param ast - The wireframe AST
383
+ * @param sceneInteractionsList - Array of scene interactions to merge
384
+ * @returns Result containing merged AST or validation errors
385
+ *
386
+ * @example
387
+ * let result = mergeInteractions(wireframeAst, interactions)
388
+ * switch result {
389
+ * | Ok(mergedAst) => Js.log("Merge successful!")
390
+ * | Error(errors) => errors->Array.forEach(err => Js.log(err))
391
+ * }
392
+ */
393
+ let mergeInteractions = (
394
+ ast: ast,
395
+ sceneInteractionsList: array<sceneInteractions>,
396
+ ): mergeResult => {
397
+ // Build map of scene IDs to element IDs
398
+ let sceneElementMap = buildSceneElementMap(ast)
399
+
400
+ // Validate all interactions
401
+ let validationErrors = validateInteractions(sceneInteractionsList, sceneElementMap)
402
+
403
+ // Separate hard errors from soft errors (ElementNotFound)
404
+ let hardErrors = validationErrors->Array.filter(error => {
405
+ switch error {
406
+ | ElementNotFound(_) => false // Soft error - element might not exist yet
407
+ | SceneNotFound(_) => true // Hard error - scene must exist
408
+ | DuplicateInteraction(_) => true // Hard error - duplicates are problematic
409
+ }
410
+ })
411
+
412
+ if hardErrors->Array.length > 0 {
413
+ // Return only hard errors
414
+ Error(hardErrors)
415
+ } else {
416
+ // Attach interactions to scenes (missing elements are silently skipped)
417
+ let enhancedScenes = ast.scenes->Array.map(scene => {
418
+ attachInteractionsToScene(scene, sceneInteractionsList)
419
+ })
420
+
421
+ // Return enhanced AST
422
+ Ok({scenes: enhancedScenes})
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Format merge error for display.
428
+ *
429
+ * @param error - The merge error to format
430
+ * @returns Human-readable error message
431
+ */
432
+ let formatError = (error: mergeError): string => {
433
+ switch error {
434
+ | ElementNotFound({sceneId, elementId}) =>
435
+ `Element "${elementId}" not found in scene "${sceneId}"`
436
+ | DuplicateInteraction({sceneId, elementId}) =>
437
+ `Duplicate interaction for element "${elementId}" in scene "${sceneId}"`
438
+ | SceneNotFound({sceneId}) => `Scene "${sceneId}" not found in wireframe`
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Format all merge errors for display.
444
+ *
445
+ * @param errors - Array of merge errors
446
+ * @returns Formatted error messages joined with newlines
447
+ */
448
+ let formatErrors = (errors: array<mergeError>): string => {
449
+ errors->Array.map(formatError)->Array.join("\n")
450
+ }