@qoretechnologies/reqraft 0.10.25 → 0.10.26

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 (310) hide show
  1. package/.claude/CLAUDE.md +52 -23
  2. package/design/COMPACT_ENGINE_REDESIGN.md +156 -0
  3. package/design/FORM_ENGINE_COMPACT_UX_PLAN.md +353 -0
  4. package/dist/components/FocusedEditing/index.d.ts +9 -1
  5. package/dist/components/FocusedEditing/index.d.ts.map +1 -1
  6. package/dist/components/FocusedEditing/index.js +2 -2
  7. package/dist/components/FocusedEditing/index.js.map +1 -1
  8. package/dist/components/composer/Composer.d.ts +108 -0
  9. package/dist/components/composer/Composer.d.ts.map +1 -0
  10. package/dist/components/composer/Composer.js +238 -0
  11. package/dist/components/composer/Composer.js.map +1 -0
  12. package/dist/components/composer/helpers.d.ts +21 -0
  13. package/dist/components/composer/helpers.d.ts.map +1 -0
  14. package/dist/components/composer/helpers.js +75 -0
  15. package/dist/components/composer/helpers.js.map +1 -0
  16. package/dist/components/composer/index.d.ts +3 -0
  17. package/dist/components/composer/index.d.ts.map +1 -0
  18. package/dist/components/composer/index.js +19 -0
  19. package/dist/components/composer/index.js.map +1 -0
  20. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  21. package/dist/components/form/engine/CompactRow.js +307 -110
  22. package/dist/components/form/engine/CompactRow.js.map +1 -1
  23. package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
  24. package/dist/components/form/engine/CompactToolbar.js +123 -106
  25. package/dist/components/form/engine/CompactToolbar.js.map +1 -1
  26. package/dist/components/form/engine/FormEngine.d.ts +107 -11
  27. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  28. package/dist/components/form/engine/FormEngine.js +618 -179
  29. package/dist/components/form/engine/FormEngine.js.map +1 -1
  30. package/dist/components/form/engine/compactRowContext.d.ts +9 -0
  31. package/dist/components/form/engine/compactRowContext.d.ts.map +1 -1
  32. package/dist/components/form/engine/compactRowContext.js.map +1 -1
  33. package/dist/components/form/engine/compactRowStyles.d.ts +7 -3
  34. package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
  35. package/dist/components/form/engine/compactRowStyles.js +88 -51
  36. package/dist/components/form/engine/compactRowStyles.js.map +1 -1
  37. package/dist/components/form/engine/compactToolbarContext.d.ts +1 -0
  38. package/dist/components/form/engine/compactToolbarContext.d.ts.map +1 -1
  39. package/dist/components/form/engine/compactToolbarContext.js.map +1 -1
  40. package/dist/components/form/engine/optionActions.d.ts +28 -0
  41. package/dist/components/form/engine/optionActions.d.ts.map +1 -0
  42. package/dist/components/form/engine/optionActions.js +18 -0
  43. package/dist/components/form/engine/optionActions.js.map +1 -0
  44. package/dist/components/form/engine/readFirst.d.ts +63 -2
  45. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  46. package/dist/components/form/engine/readFirst.js +121 -6
  47. package/dist/components/form/engine/readFirst.js.map +1 -1
  48. package/dist/components/form/engine/rendererTypes.d.ts +35 -0
  49. package/dist/components/form/engine/rendererTypes.d.ts.map +1 -0
  50. package/dist/components/form/engine/rendererTypes.js +63 -0
  51. package/dist/components/form/engine/rendererTypes.js.map +1 -0
  52. package/dist/components/form/engine/variants/VariantCalmTable.d.ts +6 -0
  53. package/dist/components/form/engine/variants/VariantCalmTable.d.ts.map +1 -0
  54. package/dist/components/form/engine/variants/VariantCalmTable.js +94 -0
  55. package/dist/components/form/engine/variants/VariantCalmTable.js.map +1 -0
  56. package/dist/components/form/engine/variants/VariantCards.d.ts +6 -0
  57. package/dist/components/form/engine/variants/VariantCards.d.ts.map +1 -0
  58. package/dist/components/form/engine/variants/VariantCards.js +80 -0
  59. package/dist/components/form/engine/variants/VariantCards.js.map +1 -0
  60. package/dist/components/form/engine/variants/VariantFocus.d.ts +7 -0
  61. package/dist/components/form/engine/variants/VariantFocus.d.ts.map +1 -0
  62. package/dist/components/form/engine/variants/VariantFocus.js +138 -0
  63. package/dist/components/form/engine/variants/VariantFocus.js.map +1 -0
  64. package/dist/components/form/engine/variants/VariantMinimal.d.ts +6 -0
  65. package/dist/components/form/engine/variants/VariantMinimal.d.ts.map +1 -0
  66. package/dist/components/form/engine/variants/VariantMinimal.js +73 -0
  67. package/dist/components/form/engine/variants/VariantMinimal.js.map +1 -0
  68. package/dist/components/form/engine/variants/focusDemo.d.ts +13 -0
  69. package/dist/components/form/engine/variants/focusDemo.d.ts.map +1 -0
  70. package/dist/components/form/engine/variants/focusDemo.js +139 -0
  71. package/dist/components/form/engine/variants/focusDemo.js.map +1 -0
  72. package/dist/components/form/engine/variants/variantModel.d.ts +70 -0
  73. package/dist/components/form/engine/variants/variantModel.d.ts.map +1 -0
  74. package/dist/components/form/engine/variants/variantModel.js +133 -0
  75. package/dist/components/form/engine/variants/variantModel.js.map +1 -0
  76. package/dist/components/form/engine/variants/variantParts.d.ts +79 -0
  77. package/dist/components/form/engine/variants/variantParts.d.ts.map +1 -0
  78. package/dist/components/form/engine/variants/variantParts.js +191 -0
  79. package/dist/components/form/engine/variants/variantParts.js.map +1 -0
  80. package/dist/components/form/expressions/builder/index.d.ts.map +1 -1
  81. package/dist/components/form/expressions/builder/index.js +13 -2
  82. package/dist/components/form/expressions/builder/index.js.map +1 -1
  83. package/dist/components/form/expressions/useExpressions.d.ts.map +1 -1
  84. package/dist/components/form/expressions/useExpressions.js +4 -1
  85. package/dist/components/form/expressions/useExpressions.js.map +1 -1
  86. package/dist/components/form/fields/Field.d.ts.map +1 -1
  87. package/dist/components/form/fields/Field.js +5 -3
  88. package/dist/components/form/fields/Field.js.map +1 -1
  89. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts +2 -1
  90. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts.map +1 -1
  91. package/dist/components/form/fields/allowed-values/AllowedValues.js +8 -26
  92. package/dist/components/form/fields/allowed-values/AllowedValues.js.map +1 -1
  93. package/dist/components/form/fields/auto/AutoFormField.d.ts +3 -0
  94. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  95. package/dist/components/form/fields/auto/AutoFormField.js +42 -8
  96. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  97. package/dist/components/form/fields/binary/Binary.d.ts +15 -0
  98. package/dist/components/form/fields/binary/Binary.d.ts.map +1 -0
  99. package/dist/components/form/fields/binary/Binary.js +74 -0
  100. package/dist/components/form/fields/binary/Binary.js.map +1 -0
  101. package/dist/components/form/fields/boolean/Boolean.js +1 -1
  102. package/dist/components/form/fields/boolean/Boolean.js.map +1 -1
  103. package/dist/components/form/fields/multi-select/MultiSelectFormField.d.ts.map +1 -1
  104. package/dist/components/form/fields/multi-select/MultiSelectFormField.js +5 -4
  105. package/dist/components/form/fields/multi-select/MultiSelectFormField.js.map +1 -1
  106. package/dist/components/form/fields/select/Select.d.ts.map +1 -1
  107. package/dist/components/form/fields/select/Select.js +29 -12
  108. package/dist/components/form/fields/select/Select.js.map +1 -1
  109. package/dist/components/form/fields/select/SelectCollection.d.ts +8 -2
  110. package/dist/components/form/fields/select/SelectCollection.d.ts.map +1 -1
  111. package/dist/components/form/fields/select/SelectCollection.js +52 -16
  112. package/dist/components/form/fields/select/SelectCollection.js.map +1 -1
  113. package/dist/components/form/index.d.ts +2 -0
  114. package/dist/components/form/index.d.ts.map +1 -1
  115. package/dist/components/form/index.js +2 -0
  116. package/dist/components/form/index.js.map +1 -1
  117. package/dist/components/imageLightbox/ImageLightbox.d.ts +21 -0
  118. package/dist/components/imageLightbox/ImageLightbox.d.ts.map +1 -0
  119. package/dist/components/imageLightbox/ImageLightbox.js +169 -0
  120. package/dist/components/imageLightbox/ImageLightbox.js.map +1 -0
  121. package/dist/components/imageLightbox/index.d.ts +2 -0
  122. package/dist/components/imageLightbox/index.d.ts.map +1 -0
  123. package/dist/components/imageLightbox/index.js +18 -0
  124. package/dist/components/imageLightbox/index.js.map +1 -0
  125. package/dist/components/searchFilterBar/SearchFilterBar.d.ts +33 -0
  126. package/dist/components/searchFilterBar/SearchFilterBar.d.ts.map +1 -0
  127. package/dist/components/searchFilterBar/SearchFilterBar.js +76 -0
  128. package/dist/components/searchFilterBar/SearchFilterBar.js.map +1 -0
  129. package/dist/components/searchFilterBar/index.d.ts +2 -0
  130. package/dist/components/searchFilterBar/index.d.ts.map +1 -0
  131. package/dist/components/searchFilterBar/index.js +18 -0
  132. package/dist/components/searchFilterBar/index.js.map +1 -0
  133. package/dist/components/supportTicket/AttachmentChips.d.ts +10 -0
  134. package/dist/components/supportTicket/AttachmentChips.d.ts.map +1 -0
  135. package/dist/components/supportTicket/AttachmentChips.js +122 -0
  136. package/dist/components/supportTicket/AttachmentChips.js.map +1 -0
  137. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts +43 -0
  138. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts.map +1 -0
  139. package/dist/components/supportTicket/InterfaceReferenceTags.js +31 -0
  140. package/dist/components/supportTicket/InterfaceReferenceTags.js.map +1 -0
  141. package/dist/components/supportTicket/ReferencePicker.d.ts +87 -0
  142. package/dist/components/supportTicket/ReferencePicker.d.ts.map +1 -0
  143. package/dist/components/supportTicket/ReferencePicker.js +295 -0
  144. package/dist/components/supportTicket/ReferencePicker.js.map +1 -0
  145. package/dist/components/supportTicket/TicketHeader.d.ts +86 -0
  146. package/dist/components/supportTicket/TicketHeader.d.ts.map +1 -0
  147. package/dist/components/supportTicket/TicketHeader.js +149 -0
  148. package/dist/components/supportTicket/TicketHeader.js.map +1 -0
  149. package/dist/components/supportTicket/TicketMetaTags.d.ts +32 -0
  150. package/dist/components/supportTicket/TicketMetaTags.d.ts.map +1 -0
  151. package/dist/components/supportTicket/TicketMetaTags.js +21 -0
  152. package/dist/components/supportTicket/TicketMetaTags.js.map +1 -0
  153. package/dist/components/supportTicket/TicketReplyBox.d.ts +63 -0
  154. package/dist/components/supportTicket/TicketReplyBox.d.ts.map +1 -0
  155. package/dist/components/supportTicket/TicketReplyBox.js +115 -0
  156. package/dist/components/supportTicket/TicketReplyBox.js.map +1 -0
  157. package/dist/components/supportTicket/attachmentAccess.d.ts +37 -0
  158. package/dist/components/supportTicket/attachmentAccess.d.ts.map +1 -0
  159. package/dist/components/supportTicket/attachmentAccess.js +132 -0
  160. package/dist/components/supportTicket/attachmentAccess.js.map +1 -0
  161. package/dist/components/supportTicket/fileToAttachment.d.ts +9 -0
  162. package/dist/components/supportTicket/fileToAttachment.d.ts.map +1 -0
  163. package/dist/components/supportTicket/fileToAttachment.js +28 -0
  164. package/dist/components/supportTicket/fileToAttachment.js.map +1 -0
  165. package/dist/components/supportTicket/index.d.ts +11 -0
  166. package/dist/components/supportTicket/index.d.ts.map +1 -0
  167. package/dist/components/supportTicket/index.js +27 -0
  168. package/dist/components/supportTicket/index.js.map +1 -0
  169. package/dist/components/supportTicket/meta.d.ts +71 -0
  170. package/dist/components/supportTicket/meta.d.ts.map +1 -0
  171. package/dist/components/supportTicket/meta.js +115 -0
  172. package/dist/components/supportTicket/meta.js.map +1 -0
  173. package/dist/components/supportTicket/ticketModel.d.ts +102 -0
  174. package/dist/components/supportTicket/ticketModel.d.ts.map +1 -0
  175. package/dist/components/supportTicket/ticketModel.js +3 -0
  176. package/dist/components/supportTicket/ticketModel.js.map +1 -0
  177. package/dist/components/ticketReferences/TicketReferences.d.ts +52 -0
  178. package/dist/components/ticketReferences/TicketReferences.d.ts.map +1 -0
  179. package/dist/components/ticketReferences/TicketReferences.js +492 -0
  180. package/dist/components/ticketReferences/TicketReferences.js.map +1 -0
  181. package/dist/components/ticketReferences/index.d.ts +3 -0
  182. package/dist/components/ticketReferences/index.d.ts.map +1 -0
  183. package/dist/components/ticketReferences/index.js +6 -0
  184. package/dist/components/ticketReferences/index.js.map +1 -0
  185. package/dist/components/ticketThread/TicketThread.d.ts +57 -0
  186. package/dist/components/ticketThread/TicketThread.d.ts.map +1 -0
  187. package/dist/components/ticketThread/TicketThread.js +299 -0
  188. package/dist/components/ticketThread/TicketThread.js.map +1 -0
  189. package/dist/components/ticketThread/index.d.ts +3 -0
  190. package/dist/components/ticketThread/index.d.ts.map +1 -0
  191. package/dist/components/ticketThread/index.js +7 -0
  192. package/dist/components/ticketThread/index.js.map +1 -0
  193. package/dist/helpers/common.d.ts +5 -0
  194. package/dist/helpers/common.d.ts.map +1 -1
  195. package/dist/helpers/common.js +20 -1
  196. package/dist/helpers/common.js.map +1 -1
  197. package/dist/helpers/scrollbar.d.ts +2 -0
  198. package/dist/helpers/scrollbar.d.ts.map +1 -0
  199. package/dist/helpers/scrollbar.js +25 -0
  200. package/dist/helpers/scrollbar.js.map +1 -0
  201. package/dist/index.d.ts +8 -1
  202. package/dist/index.d.ts.map +1 -1
  203. package/dist/index.js +10 -1
  204. package/dist/index.js.map +1 -1
  205. package/dist/providers/StorageProvider.d.ts +13 -0
  206. package/dist/providers/StorageProvider.d.ts.map +1 -1
  207. package/dist/providers/StorageProvider.js +29 -5
  208. package/dist/providers/StorageProvider.js.map +1 -1
  209. package/package.json +6 -5
  210. package/src/components/FocusedEditing/index.tsx +17 -2
  211. package/src/components/composer/Composer.stories.tsx +238 -0
  212. package/src/components/composer/Composer.tsx +545 -0
  213. package/src/components/composer/helpers.ts +74 -0
  214. package/src/components/composer/index.ts +2 -0
  215. package/src/components/dpqlEditor/DpqlEditor.stories.tsx +74 -2
  216. package/src/components/form/engine/CompactRow.tsx +561 -283
  217. package/src/components/form/engine/CompactToolbar.tsx +216 -182
  218. package/src/components/form/engine/FormEngine.stories.tsx +1736 -162
  219. package/src/components/form/engine/FormEngine.tsx +886 -170
  220. package/src/components/form/engine/FormEngineRemote.stories.tsx +377 -0
  221. package/src/components/form/engine/_structuredData/StructuredDataView.stories.tsx +8 -0
  222. package/src/components/form/engine/compactRowContext.ts +14 -0
  223. package/src/components/form/engine/compactRowStyles.ts +273 -146
  224. package/src/components/form/engine/compactToolbarContext.ts +1 -0
  225. package/src/components/form/engine/optionActions.ts +40 -0
  226. package/src/components/form/engine/readFirst.ts +169 -10
  227. package/src/components/form/engine/rendererTypes.ts +55 -0
  228. package/src/components/form/engine/variants/FormEngineVariants.stories.tsx +159 -0
  229. package/src/components/form/engine/variants/VariantCalmTable.tsx +242 -0
  230. package/src/components/form/engine/variants/VariantCards.tsx +212 -0
  231. package/src/components/form/engine/variants/VariantFocus.tsx +382 -0
  232. package/src/components/form/engine/variants/VariantMinimal.tsx +170 -0
  233. package/src/components/form/engine/variants/focusDemo.ts +145 -0
  234. package/src/components/form/engine/variants/variantModel.ts +216 -0
  235. package/src/components/form/engine/variants/variantParts.tsx +313 -0
  236. package/src/components/form/expressions/ExpressionField.stories.tsx +84 -2
  237. package/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx +213 -19
  238. package/src/components/form/expressions/builder/index.tsx +12 -1
  239. package/src/components/form/expressions/useExpressions.ts +4 -1
  240. package/src/components/form/fields/Field.stories.tsx +40 -0
  241. package/src/components/form/fields/Field.tsx +10 -1
  242. package/src/components/form/fields/allowed-values/AllowedValues.tsx +13 -36
  243. package/src/components/form/fields/array/ArrayAutoField.stories.tsx +64 -0
  244. package/src/components/form/fields/auto/AutoFormField.stories.tsx +146 -2
  245. package/src/components/form/fields/auto/AutoFormField.tsx +42 -1
  246. package/src/components/form/fields/binary/Binary.stories.tsx +122 -0
  247. package/src/components/form/fields/binary/Binary.tsx +81 -0
  248. package/src/components/form/fields/boolean/Boolean.stories.tsx +58 -0
  249. package/src/components/form/fields/boolean/Boolean.tsx +1 -1
  250. package/src/components/form/fields/byte-size/ByteSize.stories.tsx +24 -0
  251. package/src/components/form/fields/color/Color.stories.tsx +30 -0
  252. package/src/components/form/fields/cron/Cron.stories.tsx +8 -0
  253. package/src/components/form/fields/date/Date.stories.tsx +22 -0
  254. package/src/components/form/fields/file/File.stories.tsx +48 -0
  255. package/src/components/form/fields/long-string/LongString.stories.tsx +24 -0
  256. package/src/components/form/fields/markdown/Markdown.stories.tsx +8 -0
  257. package/src/components/form/fields/multi-select/MultiSelectFormField.tsx +14 -10
  258. package/src/components/form/fields/number/Number.stories.tsx +31 -0
  259. package/src/components/form/fields/object/Object.stories.tsx +88 -0
  260. package/src/components/form/fields/radio-group/RadioGroup.stories.tsx +24 -0
  261. package/src/components/form/fields/rich-text/RichText.stories.tsx +23 -0
  262. package/src/components/form/fields/schema-definition/SchemaDefinitionField.stories.tsx +72 -0
  263. package/src/components/form/fields/select/Select.stories.tsx +108 -0
  264. package/src/components/form/fields/select/Select.tsx +38 -12
  265. package/src/components/form/fields/select/SelectCollection.tsx +78 -17
  266. package/src/components/form/fields/string/String.stories.tsx +16 -0
  267. package/src/components/form/fields/template/TemplateField.stories.tsx +160 -0
  268. package/src/components/form/fields/url/Url.stories.tsx +24 -0
  269. package/src/components/form/index.tsx +2 -0
  270. package/src/components/imageLightbox/ImageLightbox.stories.tsx +238 -0
  271. package/src/components/imageLightbox/ImageLightbox.tsx +236 -0
  272. package/src/components/imageLightbox/index.ts +1 -0
  273. package/src/components/log/Log.stories.tsx +96 -0
  274. package/src/components/menu/Menu.stories.tsx +54 -0
  275. package/src/components/qonsoleSmartInput/QonsoleSmartInput.stories.tsx +83 -37
  276. package/src/components/searchFilterBar/SearchFilterBar.tsx +131 -0
  277. package/src/components/searchFilterBar/index.ts +1 -0
  278. package/src/components/smartEditor/SmartEditor.stories.tsx +32 -0
  279. package/src/components/supportTicket/AttachmentChips.stories.tsx +171 -0
  280. package/src/components/supportTicket/AttachmentChips.tsx +147 -0
  281. package/src/components/supportTicket/InterfaceReferenceTags.stories.tsx +171 -0
  282. package/src/components/supportTicket/InterfaceReferenceTags.tsx +96 -0
  283. package/src/components/supportTicket/ReferencePicker.stories.tsx +519 -0
  284. package/src/components/supportTicket/ReferencePicker.tsx +563 -0
  285. package/src/components/supportTicket/TicketConversation.stories.tsx +121 -0
  286. package/src/components/supportTicket/TicketHeader.stories.tsx +183 -0
  287. package/src/components/supportTicket/TicketHeader.tsx +247 -0
  288. package/src/components/supportTicket/TicketMetaTags.stories.tsx +103 -0
  289. package/src/components/supportTicket/TicketMetaTags.tsx +85 -0
  290. package/src/components/supportTicket/TicketReplyBox.stories.tsx +351 -0
  291. package/src/components/supportTicket/TicketReplyBox.tsx +146 -0
  292. package/src/components/supportTicket/attachmentAccess.ts +97 -0
  293. package/src/components/supportTicket/fileToAttachment.ts +24 -0
  294. package/src/components/supportTicket/index.ts +10 -0
  295. package/src/components/supportTicket/meta.ts +185 -0
  296. package/src/components/supportTicket/ticketModel.ts +131 -0
  297. package/src/components/ticketReferences/TicketReferences.stories.tsx +315 -0
  298. package/src/components/ticketReferences/TicketReferences.tsx +886 -0
  299. package/src/components/ticketReferences/index.ts +2 -0
  300. package/src/components/ticketThread/TicketThread.stories.tsx +828 -0
  301. package/src/components/ticketThread/TicketThread.tsx +704 -0
  302. package/src/components/ticketThread/index.ts +6 -0
  303. package/src/helpers/common.ts +19 -0
  304. package/src/helpers/scrollbar.ts +32 -0
  305. package/src/hooks/useFetch/useFetch.stories.tsx +30 -0
  306. package/src/hooks/useStorage/useStorage.stories.tsx +82 -0
  307. package/src/hooks/useWebSocket/useWebsocket.stories.tsx +127 -1
  308. package/src/index.tsx +8 -1
  309. package/src/providers/StorageProvider.tsx +30 -4
  310. package/src/stores/currentUser/currentUser.stories.tsx +49 -0
@@ -5,6 +5,7 @@ import { Meta, StoryObj } from '@storybook/react-vite';
5
5
  import { ChangeEvent, useState } from 'react';
6
6
  import { expect, fireEvent, fn, userEvent, waitFor, within } from 'storybook/test';
7
7
  import { validateField } from '../../../helpers/validations';
8
+ import { defaultQorusTypes } from '../../../hooks/useQorusTypes';
8
9
  import {
9
10
  _testsChangeRichText,
10
11
  _testsChangeStringField,
@@ -90,6 +91,29 @@ const meta: Meta<typeof FormEngine> = {
90
91
  chromatic: {
91
92
  viewports: [2560],
92
93
  },
94
+ // `useQorusTypes` resolves the type catalogue as `size(data) ? data : defaultQorusTypes`,
95
+ // so a reachable, authenticated instance *replaces* the built-in list rather than
96
+ // supplementing it. These stories never opt into live data (no `live: true`), but the
97
+ // request fires anyway — which made `Option With Any Type` pass locally (401 → built-in
98
+ // list, so "Boolean" exists) and fail in CI, where the token is valid and the server's
99
+ // list decides the labels. Pin the catalogue so the type names the plays click are ours.
100
+ mockData: [
101
+ {
102
+ // `query()` builds `${instance}api/latest/${url}`, and the hook's url is
103
+ // `/system/qorus-type-info` — hence the doubled slash. Both spellings are listed
104
+ // so the mock keeps matching if that leading slash is ever dropped.
105
+ url: 'https://hq.qoretechnologies.com:8092/api/latest//system/qorus-type-info',
106
+ method: 'GET',
107
+ status: 200,
108
+ response: defaultQorusTypes,
109
+ },
110
+ {
111
+ url: 'https://hq.qoretechnologies.com:8092/api/latest/system/qorus-type-info',
112
+ method: 'GET',
113
+ status: 200,
114
+ response: defaultQorusTypes,
115
+ },
116
+ ],
93
117
  },
94
118
  render: ({ value, onChange, ...rest }: IFormEngineProps) => {
95
119
  const [val, setValue] = useState(value);
@@ -152,6 +176,14 @@ type Story = StoryObj<typeof meta>;
152
176
  // stories
153
177
 
154
178
  export const Basic: Story = {
179
+ parameters: {
180
+ docs: {
181
+ description: {
182
+ story:
183
+ 'Renders FormEngine over the shared basic-schema fixture — every option and value the classic layout exercises (booleans, strings with values, templates, invalid types) is present.',
184
+ },
185
+ },
186
+ },
155
187
  args: {
156
188
  minColumnWidth: '300px',
157
189
  options: getOptions(),
@@ -188,6 +220,14 @@ export const Basic: Story = {
188
220
 
189
221
  export const Small: Story = {
190
222
  ...Basic,
223
+ parameters: {
224
+ docs: {
225
+ description: {
226
+ story:
227
+ 'Renders the Basic FormEngine at size=small — the same fixture but with the compact size preset applied to every control.',
228
+ },
229
+ },
230
+ },
191
231
  args: {
192
232
  ...Basic.args,
193
233
  size: 'small',
@@ -196,6 +236,14 @@ export const Small: Story = {
196
236
 
197
237
  export const InvalidShownOnly: Story = {
198
238
  ...Basic,
239
+ parameters: {
240
+ docs: {
241
+ description: {
242
+ story:
243
+ 'Renders the Basic FormEngine, then clicks the invalid-fields message chip in the header — only the invalid options stay visible.',
244
+ },
245
+ },
246
+ },
199
247
  play: async (args) => {
200
248
  await Basic.play!(args);
201
249
  await fireEvent.click(document.querySelector('.reqore-message')!);
@@ -211,6 +259,14 @@ export const InvalidShownOnly: Story = {
211
259
  };
212
260
 
213
261
  export const Optional: Story = {
262
+ parameters: {
263
+ docs: {
264
+ description: {
265
+ story:
266
+ 'Renders FormEngine with only the optional half of the basic schema — the More Options Available collapsible box is shown but not opened.',
267
+ },
268
+ },
269
+ },
214
270
  args: {
215
271
  minColumnWidth: '300px',
216
272
  options: getOptions(true),
@@ -218,6 +274,14 @@ export const Optional: Story = {
218
274
  };
219
275
 
220
276
  export const OptionalOpened: Story = {
277
+ parameters: {
278
+ docs: {
279
+ description: {
280
+ story:
281
+ 'Renders FormEngine with only the optional half of the basic schema, then clicks the More Options Available banner — the optional fields drop down into the form.',
282
+ },
283
+ },
284
+ },
221
285
  args: {
222
286
  minColumnWidth: '300px',
223
287
  options: getOptions(true),
@@ -236,6 +300,14 @@ export const OptionalOpened: Story = {
236
300
 
237
301
  export const FocusedEditing: Story = {
238
302
  ...Basic,
303
+ parameters: {
304
+ docs: {
305
+ description: {
306
+ story:
307
+ 'Renders the Basic FormEngine, hovers an option and clicks its fullscreen action — the Focused Editing modal opens over that single field.',
308
+ },
309
+ },
310
+ },
239
311
  play: async (args) => {
240
312
  await Basic.play!(args);
241
313
  await userEvent.hover(document.querySelectorAll('.system-option')[0]);
@@ -246,6 +318,14 @@ export const FocusedEditing: Story = {
246
318
 
247
319
  export const DescriptionIsShown: Story = {
248
320
  ...Basic,
321
+ parameters: {
322
+ docs: {
323
+ description: {
324
+ story:
325
+ 'Renders the Basic FormEngine and clicks the Option with description label — the help panel opens with the option\'s long-form description.',
326
+ },
327
+ },
328
+ },
249
329
  play: async ({ canvasElement, ...rest }) => {
250
330
  const canvas = within(canvasElement);
251
331
  await Basic.play!({ canvasElement, ...rest });
@@ -256,6 +336,14 @@ export const DescriptionIsShown: Story = {
256
336
  };
257
337
 
258
338
  export const ValueCanBeRemoved: Story = {
339
+ parameters: {
340
+ docs: {
341
+ description: {
342
+ story:
343
+ 'Renders FormEngine holding a text option and a file option, both with values. Hovering each row and clicking its remove action clears the value and marks the row as revertable.',
344
+ },
345
+ },
346
+ },
259
347
  args: {
260
348
  options: {
261
349
  textOption: {
@@ -300,6 +388,14 @@ export const ValueCanBeRemoved: Story = {
300
388
 
301
389
  export const ChangeCanBeReverted: Story = {
302
390
  ...ValueCanBeRemoved,
391
+ parameters: {
392
+ docs: {
393
+ description: {
394
+ story:
395
+ 'Renders the ValueCanBeRemoved fixture after both values are removed, then clicks the per-row revert action — the file value comes back.',
396
+ },
397
+ },
398
+ },
303
399
  play: async (args) => {
304
400
  await ValueCanBeRemoved.play!(args);
305
401
  await _testsClickButton({ selector: '.options-item-revert', nth: 1 });
@@ -309,6 +405,14 @@ export const ChangeCanBeReverted: Story = {
309
405
 
310
406
  export const AllChangesCanBeReverted: Story = {
311
407
  ...ValueCanBeRemoved,
408
+ parameters: {
409
+ docs: {
410
+ description: {
411
+ story:
412
+ 'Renders the ValueCanBeRemoved fixture after both values are removed, then clicks the form-level revert action — the entire form goes back to its original values.',
413
+ },
414
+ },
415
+ },
312
416
  play: async (args) => {
313
417
  await ValueCanBeRemoved.play!(args);
314
418
  await _testsClickButton({ selector: '.fields-revert' });
@@ -318,6 +422,14 @@ export const AllChangesCanBeReverted: Story = {
318
422
 
319
423
  export const WithTypesShown: Story = {
320
424
  ...Basic,
425
+ parameters: {
426
+ docs: {
427
+ description: {
428
+ story:
429
+ 'Renders the Basic FormEngine and clicks the show-types header action — every option label picks up its Qore type badge (e.g. <rgbcolor>).',
430
+ },
431
+ },
432
+ },
321
433
  play: async (args) => {
322
434
  await Basic.play!(args);
323
435
  await _testsClickButton({ selector: '.fields-show-types' });
@@ -326,6 +438,14 @@ export const WithTypesShown: Story = {
326
438
  };
327
439
 
328
440
  export const WithRequiredGroups: Story = {
441
+ parameters: {
442
+ docs: {
443
+ description: {
444
+ story:
445
+ 'Renders FormEngine with five options that all belong to one required_groups group — every row mounts and the group\'s one-of-required indicator is shown.',
446
+ },
447
+ },
448
+ },
329
449
  args: {
330
450
  minColumnWidth: '300px',
331
451
  options: TestOptionsWithRequiredGroups,
@@ -338,6 +458,14 @@ export const WithRequiredGroups: Story = {
338
458
  };
339
459
 
340
460
  export const WithRequiredGroupsFulfilled: Story = {
461
+ parameters: {
462
+ docs: {
463
+ description: {
464
+ story:
465
+ 'Renders the required-group schema with one of the group\'s options already filled — the group\'s one-of-required indicator marks the group as satisfied.',
466
+ },
467
+ },
468
+ },
341
469
  args: {
342
470
  minColumnWidth: '300px',
343
471
  options: TestOptionsWithRequiredGroups,
@@ -353,6 +481,14 @@ export const WithRequiredGroupsFulfilled: Story = {
353
481
  };
354
482
 
355
483
  export const OptionDependsOnOptionOrAnotherOption: Story = {
484
+ parameters: {
485
+ docs: {
486
+ description: {
487
+ story:
488
+ 'Renders FormEngine with a required option that depends on Required Option 2 OR Required Option 5 — the field is disabled until either dependency is filled, then the disabled note clears.',
489
+ },
490
+ },
491
+ },
356
492
  args: {
357
493
  minColumnWidth: '300px',
358
494
  options: {
@@ -383,6 +519,14 @@ export const OptionDependsOnOptionOrAnotherOption: Story = {
383
519
  };
384
520
 
385
521
  export const OptionDependsOnOptionInRequiredGroup: Story = {
522
+ parameters: {
523
+ docs: {
524
+ description: {
525
+ story:
526
+ 'Renders FormEngine with a required option that depends on Required Option 2 alone — filling Required Option 2 clears the disabled note.',
527
+ },
528
+ },
529
+ },
386
530
  args: {
387
531
  minColumnWidth: '300px',
388
532
  options: {
@@ -413,6 +557,14 @@ export const OptionDependsOnOptionInRequiredGroup: Story = {
413
557
  };
414
558
 
415
559
  export const OptionalWithValues: Story = {
560
+ parameters: {
561
+ docs: {
562
+ description: {
563
+ story:
564
+ 'Renders FormEngine with only the optional half of the basic schema plus pre-existing values — the optional fields are already populated and the More Options Available banner is hidden.',
565
+ },
566
+ },
567
+ },
416
568
  args: {
417
569
  minColumnWidth: '300px',
418
570
  options: getOptions(true),
@@ -425,6 +577,14 @@ export const OptionalWithValues: Story = {
425
577
  };
426
578
 
427
579
  export const OptionWithAnyType: Story = {
580
+ parameters: {
581
+ docs: {
582
+ description: {
583
+ story:
584
+ 'Renders four options typed as any with templates enabled — empty ones show a Select Template dropdown, the pre-typed number field renders as a Number input and the operator can switch types via the More menu.',
585
+ },
586
+ },
587
+ },
428
588
  args: {
429
589
  options: {
430
590
  optionWithAnyType: {
@@ -486,11 +646,19 @@ export const OptionWithAnyType: Story = {
486
646
  // and switch it to a specific custom type (Boolean).
487
647
  await _testsOpenTemplateMenu(4);
488
648
  await _testsClickButton({ label: 'Set Custom Value' });
489
- await _testsClickButton({ label: 'True or False' });
649
+ await _testsClickButton({ label: 'Boolean' });
490
650
  },
491
651
  };
492
652
 
493
653
  export const NonExistentOptionsFiltered: Story = {
654
+ parameters: {
655
+ docs: {
656
+ description: {
657
+ story:
658
+ 'Renders FormEngine with a value that carries three fields but a schema that declares only two — the extra option is filtered out and onChange fires without it.',
659
+ },
660
+ },
661
+ },
494
662
  args: {
495
663
  value: {
496
664
  option1: { type: 'long-string', value: 'option1' },
@@ -526,6 +694,14 @@ export const NonExistentOptionsFiltered: Story = {
526
694
  };
527
695
 
528
696
  export const OptionsWithOnChangeTriggerEvents: Story = {
697
+ parameters: {
698
+ docs: {
699
+ description: {
700
+ story:
701
+ 'Renders FormEngine with an option that declares on_change: [\'refetch\']. Editing the field fires onChange with meta.events set to [\'refetch\'] so the host can re-fetch dependent options.',
702
+ },
703
+ },
704
+ },
529
705
  args: {
530
706
  value: {
531
707
  optionWithRefetchAndReset: { type: 'long-string', value: 'option1' },
@@ -565,7 +741,436 @@ export const OptionsWithOnChangeTriggerEvents: Story = {
565
741
  },
566
742
  };
567
743
 
744
+ // A stand-in for a host-injected `code-editor` renderer. Reqraft does not
745
+ // ship a CodeEditor (Monaco is a heavy dep the toolkit shouldn't pull in);
746
+ // the IDE / consumers inject one via `componentOverrides`. This stand-in
747
+ // renders a textarea AND a visible "syntax: <language>" tag so the
748
+ // `inherit_props` story (below) can prove the inherited `language` prop
749
+ // flows through to the renderer at render time.
750
+ const CodeEditorStandin = ({
751
+ value,
752
+ onChange,
753
+ language,
754
+ size,
755
+ }: {
756
+ value?: unknown;
757
+ onChange?: (value: string) => void;
758
+ language?: unknown;
759
+ size?: TSizes;
760
+ }) => (
761
+ <div data-testid='code-editor-mock'>
762
+ <div data-testid='code-editor-language' style={{ fontFamily: 'monospace', marginBottom: 4 }}>
763
+ syntax: {String(language ?? 'plain')}
764
+ </div>
765
+ <ReqoreInput
766
+ fluid
767
+ size={size}
768
+ icon='CodeLine'
769
+ placeholder='Source code (stand-in code-editor)'
770
+ value={typeof value === 'string' ? value : ''}
771
+ onChange={(event: ChangeEvent<HTMLInputElement>) => onChange?.(event.target.value)}
772
+ />
773
+ </div>
774
+ );
775
+
776
+ // qorus#347-followup: an option can declare `inherit_props` — a JSON map of
777
+ // `<prop-name-on-renderer>` → `<sibling-field-name>` — and FormEngine
778
+ // resolves each entry at render time, forwarding the sibling's current
779
+ // value as a runtime prop on this field's renderer. Unlike `on_change:
780
+ // ['refetch']` (which requires a server round-trip to reshape the
781
+ // schema), `inherit_props` is purely a render-time prop forwarding —
782
+ // fast, JSON-safe, and the receiving renderer decides how to use the
783
+ // value (e.g. CodeEditor maps `language: "qore"` to its highlighter
784
+ // mode). This story demonstrates the canonical case: a `source` field
785
+ // (rendered by a host-injected code-editor) inherits `language` from
786
+ // a sibling `lang` picker, so flipping the picker live-changes the
787
+ // editor's syntax highlighting with no refetch.
788
+ export const OptionInheritsRenderPropFromSibling: Story = {
789
+ parameters: {
790
+ docs: {
791
+ description: {
792
+ story:
793
+ 'Renders FormEngine with a code-editor field that declares inherit_props: { language: \'lang\' } — the sibling language picker feeds the editor\'s language prop at render time, and flipping the picker live-updates the syntax without any refetch.',
794
+ },
795
+ },
796
+ },
797
+ args: {
798
+ componentOverrides: { 'code-editor': CodeEditorStandin },
799
+ value: {
800
+ lang: { type: 'string', value: 'qore' },
801
+ source: { type: 'string', value: 'sub run() { print("hello"); }' },
802
+ },
803
+ options: {
804
+ lang: {
805
+ type: 'string',
806
+ ui_type: 'string',
807
+ display_name: 'Language',
808
+ allowed_values: [
809
+ { display_name: 'Qore', value: { type: 'string', value: 'qore' } },
810
+ { display_name: 'Python', value: { type: 'string', value: 'python' } },
811
+ { display_name: 'Java', value: { type: 'string', value: 'java' } },
812
+ ],
813
+ },
814
+ source: {
815
+ type: 'string',
816
+ ui_type: 'code-editor',
817
+ display_name: 'Source Code',
818
+ // The feature under test: the `language` prop of the code-editor
819
+ // renderer is sourced from sibling `lang`'s current value at
820
+ // render time. No `on_change` refetch — instant prop forwarding.
821
+ inherit_props: { language: 'lang' },
822
+ },
823
+ },
824
+ },
825
+ play: async ({ canvasElement }) => {
826
+ const canvas = within(canvasElement);
827
+
828
+ // Initial state: lang = "qore" → editor renderer receives language="qore"
829
+ await waitFor(
830
+ () =>
831
+ expect(canvas.getByTestId('code-editor-language')).toHaveTextContent('syntax: qore'),
832
+ { timeout: 5000 }
833
+ );
834
+
835
+ // Change lang → python via the select; the code-editor's `language`
836
+ // prop should re-resolve and the visible "syntax:" tag should update
837
+ // without any extra clicks or refetches.
838
+ const langField = (
839
+ canvasElement.querySelectorAll('.system-option') as NodeListOf<HTMLElement>
840
+ )[0];
841
+ const langSelectTrigger = within(langField).getByText('Qore');
842
+ await userEvent.click(langSelectTrigger);
843
+ const pythonItem = await within(document.body).findByText('Python');
844
+ await userEvent.click(pythonItem);
845
+
846
+ await waitFor(
847
+ () =>
848
+ expect(canvas.getByTestId('code-editor-language')).toHaveTextContent('syntax: python'),
849
+ { timeout: 5000 }
850
+ );
851
+ },
852
+ };
853
+
854
+ // qorus#347-followup, compact variant of the FLAT case: same schema and
855
+ // componentOverride as `OptionInheritsRenderPropFromSibling`, but the form
856
+ // is rendered with `compact: true`. Compact and classic share the same
857
+ // `renderOption` callback (FormEngine.tsx:1590) where `inherit_props` is
858
+ // resolved onto `TemplateField`, so the forwarding mechanism is identical
859
+ // in both modes. This story locks that in so a future refactor of the
860
+ // compact path can't silently break inherit_props for read-first surfaces.
861
+ export const OptionInheritsRenderPropFromSiblingCompact: Story = {
862
+ parameters: {
863
+ docs: {
864
+ description: {
865
+ story:
866
+ 'Renders the OptionInheritsRenderPropFromSibling schema with compact=true — the same inherit_props forwarding runs through the compact renderer.',
867
+ },
868
+ },
869
+ },
870
+ args: {
871
+ compact: true,
872
+ minColumnWidth: '300px',
873
+ componentOverrides: { 'code-editor': CodeEditorStandin },
874
+ value: {
875
+ lang: { type: 'string', value: 'qore' },
876
+ source: { type: 'string', value: 'sub run() { print("hello"); }' },
877
+ },
878
+ options: {
879
+ lang: {
880
+ type: 'string',
881
+ ui_type: 'string',
882
+ display_name: 'Language',
883
+ allowed_values: [
884
+ { display_name: 'Qore', value: { type: 'string', value: 'qore' } },
885
+ { display_name: 'Python', value: { type: 'string', value: 'python' } },
886
+ { display_name: 'Java', value: { type: 'string', value: 'java' } },
887
+ ],
888
+ },
889
+ source: {
890
+ type: 'string',
891
+ ui_type: 'code-editor',
892
+ display_name: 'Source Code',
893
+ inherit_props: { language: 'lang' },
894
+ },
895
+ },
896
+ },
897
+ play: async ({ canvasElement }) => {
898
+ const canvas = within(canvasElement);
899
+ // Compact renders rows collapsed by default. Structural check: the
900
+ // schema arrives intact + the source-code row is present. The full
901
+ // interaction (lang flip -> language prop updates) is covered by the
902
+ // classic `OptionInheritsRenderPropFromSibling` story.
903
+ await waitFor(
904
+ () => expect(canvas.getAllByText('Source Code').length).toBeGreaterThan(0),
905
+ { timeout: 5000 }
906
+ );
907
+ },
908
+ };
909
+
910
+ // Compact-row code-editor preview: a `code-editor` field with a multi-line
911
+ // string value renders (a) a "N lines · N chars" tag in the value cell instead
912
+ // of the truncated raw string, and (b) a monospace `<pre>` block under the row
913
+ // capped by a `ReqoreCollapsibleContent` — the "Show more" affordance the value
914
+ // cell couldn't provide on its own. Locks the compact preview so a future
915
+ // CompactRow refactor can't silently reduce a Qorus source-code field to an
916
+ // ellipsised one-liner again.
917
+ export const CompactRowCodeEditorPreview: Story = {
918
+ parameters: {
919
+ docs: {
920
+ description: {
921
+ story:
922
+ 'Renders a compact-mode code-editor row over a multi-line Qore source value — the value cell replaces the truncated string with a lines/chars summary tag and a collapsible monospace preview mounts under the row.',
923
+ },
924
+ },
925
+ },
926
+ args: {
927
+ compact: true,
928
+ minColumnWidth: '360px',
929
+ componentOverrides: { 'code-editor': CodeEditorStandin },
930
+ value: {
931
+ language: { type: 'string', value: 'qore' },
932
+ source: {
933
+ type: 'string',
934
+ value:
935
+ '%new-style\n%require-types\n%strict-args\n' +
936
+ '%enable-all-warnings\n\n' +
937
+ 'class ExampleJob inherits QorusJob {\n' +
938
+ ' run() {\n' +
939
+ ' logInfo("running");\n' +
940
+ ' }\n' +
941
+ '}\n',
942
+ },
943
+ },
944
+ options: {
945
+ language: {
946
+ type: 'string',
947
+ ui_type: 'string',
948
+ display_name: 'Language',
949
+ allowed_values: [
950
+ { display_name: 'Qore', value: { type: 'string', value: 'qore' } },
951
+ { display_name: 'Python', value: { type: 'string', value: 'python' } },
952
+ { display_name: 'Java', value: { type: 'string', value: 'java' } },
953
+ ],
954
+ },
955
+ source: {
956
+ type: 'string',
957
+ ui_type: 'code-editor',
958
+ display_name: 'Source Code',
959
+ inherit_props: { language: 'language' },
960
+ },
961
+ },
962
+ },
963
+ play: async ({ canvasElement }) => {
964
+ // (a) The monospace preview mounted under the row — contains a substring
965
+ // only the source has, proving `showCodePreview` kicked in and the
966
+ // `StyledCodePreview` block is in the DOM.
967
+ await waitFor(
968
+ () => {
969
+ const preview = canvasElement.querySelector('.options-readfirst-code');
970
+ expect(preview).toBeTruthy();
971
+ expect(preview?.textContent).toContain('class ExampleJob inherits QorusJob');
972
+ },
973
+ { timeout: 5000 }
974
+ );
975
+ // (b) The value cell replaced its truncated raw string with a summary tag —
976
+ // query the tag directly (its label + labelKey render on separate spans,
977
+ // so text-matching across them is fragile). Look for the CodeLine icon
978
+ // that only this tag mounts alongside the source-code row.
979
+ const sourceRow = canvasElement.querySelector('[data-field="source"]');
980
+ expect(sourceRow).toBeTruthy();
981
+ expect(sourceRow?.textContent ?? '').toMatch(/\d+\s*lines?/);
982
+ },
983
+ };
984
+
985
+ // qorus#347-followup, scope forwarding: this story exercises the nested
986
+ // case of the OptionInheritsRenderPropFromSibling contract. The parent
987
+ // form declares `methods: { ui_type: 'list', element_type: 'hash',
988
+ // arg_schema: {...}, inherit_props: { language: 'language' } }`. FormEngine
989
+ // resolves the parent's inherit_props against the top-level `language`
990
+ // field, threads the resolved bag through the ArrayAuto row wrapper into
991
+ // each row's arg_schema sub-form as `inheritedFromParent`. The row's
992
+ // `body` sub-field ALSO declares `inherit_props: { language: 'language' }`;
993
+ // its `availableOptions` has no `language` (rows only carry name /
994
+ // description / body), so the resolver falls back to
995
+ // `inheritedFromParent.language` and threads it as the CodeEditor's
996
+ // `language` prop. Flipping the top-level lang picker live-updates every
997
+ // row's editor without any custom per-field wiring.
998
+ export const NestedOptionInheritsRenderPropFromAncestor: Story = {
999
+ parameters: {
1000
+ docs: {
1001
+ description: {
1002
+ story:
1003
+ 'Renders FormEngine with a list-of-hash methods option whose row sub-schema declares inherit_props: { language: \'language\' } — the parent-level forwarding threads the top-level language down to every row\'s code-editor body sub-field.',
1004
+ },
1005
+ },
1006
+ },
1007
+ args: {
1008
+ componentOverrides: { 'code-editor': CodeEditorStandin },
1009
+ value: {
1010
+ language: { type: 'string', value: 'qore' },
1011
+ methods: {
1012
+ type: 'list',
1013
+ value: [
1014
+ { type: 'hash', value: { name: 'init', body: 'sub init() { }' } },
1015
+ { type: 'hash', value: { name: 'run', body: 'sub run() { print("hi"); }' } },
1016
+ ],
1017
+ },
1018
+ },
1019
+ options: {
1020
+ language: {
1021
+ type: 'string',
1022
+ ui_type: 'string',
1023
+ display_name: 'Language',
1024
+ allowed_values: [
1025
+ { display_name: 'Qore', value: { type: 'string', value: 'qore' } },
1026
+ { display_name: 'Python', value: { type: 'string', value: 'python' } },
1027
+ { display_name: 'Java', value: { type: 'string', value: 'java' } },
1028
+ ],
1029
+ },
1030
+ methods: {
1031
+ type: 'list',
1032
+ ui_type: 'list',
1033
+ element_type: 'hash',
1034
+ display_name: 'Methods',
1035
+ // Parent-level declaration: forward top-level `language` down into
1036
+ // each row's arg_schema sub-form so per-method `body` sub-fields
1037
+ // can pick it up as `language` prop without knowing about the
1038
+ // ancestor scope.
1039
+ inherit_props: { language: 'language' },
1040
+ arg_schema: {
1041
+ name: {
1042
+ type: 'string',
1043
+ ui_type: 'string',
1044
+ display_name: 'Method Name',
1045
+ },
1046
+ body: {
1047
+ type: 'string',
1048
+ ui_type: 'code-editor',
1049
+ display_name: 'Method Body',
1050
+ // Row-level declaration: the resolver walks
1051
+ // 1. local availableOptions (row only has name + body — no
1052
+ // language here),
1053
+ // 2. `inheritedFromParent` (populated by the parent-level
1054
+ // `methods.inherit_props` above — has language).
1055
+ inherit_props: { language: 'language' },
1056
+ },
1057
+ },
1058
+ },
1059
+ } as unknown as IOptionsSchema,
1060
+ },
1061
+ play: async ({ canvasElement }) => {
1062
+ const canvas = within(canvasElement);
1063
+
1064
+ // Two rows -> two code-editor stand-ins -> each shows "syntax: qore"
1065
+ // at initial render, sourced from the top-level `language` field via
1066
+ // parent -> row scope forwarding.
1067
+ await waitFor(
1068
+ () => {
1069
+ const tags = canvas.getAllByTestId('code-editor-language');
1070
+ expect(tags).toHaveLength(2);
1071
+ tags.forEach((tag) => expect(tag).toHaveTextContent('syntax: qore'));
1072
+ },
1073
+ { timeout: 5000 }
1074
+ );
1075
+ },
1076
+ };
1077
+
1078
+ // qorus#347-followup, scope forwarding + compact variant: same schema as
1079
+ // `NestedOptionInheritsRenderPropFromAncestor` but with `compact: true`.
1080
+ // Compact and classic share the `renderOption` callback (FormEngine.tsx:1590)
1081
+ // which is where the `inheritedFromParent` bag is threaded onto TemplateField,
1082
+ // so the forwarding mechanism is identical in both modes. This story locks
1083
+ // that in — a compact rendering of a parent form with a nested arg_schema
1084
+ // list-of-hash whose sub-fields still resolve `language` from the top-level
1085
+ // picker through the same two-hop chain.
1086
+ export const NestedOptionInheritsRenderPropFromAncestorCompact: Story = {
1087
+ parameters: {
1088
+ docs: {
1089
+ description: {
1090
+ story:
1091
+ 'Renders the NestedOptionInheritsRenderPropFromAncestor schema with compact=true — the compact renderer summarises the list-of-hash rows as \'init, run\' rather than [object Object].',
1092
+ },
1093
+ },
1094
+ },
1095
+ args: {
1096
+ compact: true,
1097
+ minColumnWidth: '300px',
1098
+ componentOverrides: { 'code-editor': CodeEditorStandin },
1099
+ value: {
1100
+ language: { type: 'string', value: 'qore' },
1101
+ methods: {
1102
+ type: 'list',
1103
+ value: [
1104
+ { type: 'hash', value: { name: 'init', body: 'sub init() { }' } },
1105
+ { type: 'hash', value: { name: 'run', body: 'sub run() { print("hi"); }' } },
1106
+ ],
1107
+ },
1108
+ },
1109
+ options: {
1110
+ language: {
1111
+ type: 'string',
1112
+ ui_type: 'string',
1113
+ display_name: 'Language',
1114
+ allowed_values: [
1115
+ { display_name: 'Qore', value: { type: 'string', value: 'qore' } },
1116
+ { display_name: 'Python', value: { type: 'string', value: 'python' } },
1117
+ { display_name: 'Java', value: { type: 'string', value: 'java' } },
1118
+ ],
1119
+ },
1120
+ methods: {
1121
+ type: 'list',
1122
+ ui_type: 'list',
1123
+ element_type: 'hash',
1124
+ display_name: 'Methods',
1125
+ inherit_props: { language: 'language' },
1126
+ arg_schema: {
1127
+ name: {
1128
+ type: 'string',
1129
+ ui_type: 'string',
1130
+ display_name: 'Method Name',
1131
+ },
1132
+ body: {
1133
+ type: 'string',
1134
+ ui_type: 'code-editor',
1135
+ display_name: 'Method Body',
1136
+ inherit_props: { language: 'language' },
1137
+ },
1138
+ },
1139
+ },
1140
+ } as unknown as IOptionsSchema,
1141
+ },
1142
+ play: async ({ canvasElement }) => {
1143
+ const canvas = within(canvasElement);
1144
+
1145
+ // Compact renders each option collapsed into a read-first row. The
1146
+ // methods row needs a click to expand, then the nested list rows each
1147
+ // need a click to expand and reveal the body sub-field's editor with
1148
+ // the inherited language. Rather than driving that whole editing
1149
+ // flow (which is what the CompactBasic / CompactExpressions stories
1150
+ // already exercise), assert on the STRUCTURAL element: the schema
1151
+ // arrived intact through `inheritedFromParent` and the row-level
1152
+ // options include the body field wired to the code-editor override.
1153
+ await waitFor(
1154
+ () => expect(canvas.getAllByText('Methods').length).toBeGreaterThan(0),
1155
+ { timeout: 5000 }
1156
+ );
1157
+ // The list-of-hashes value summarises by the items' names — never a raw
1158
+ // "[object Object]" (regression: it used to stringify each hash envelope).
1159
+ await expect(await canvas.findByText('init, run', undefined, { timeout: 5000 }))
1160
+ .toBeInTheDocument();
1161
+ await expect(canvasElement.textContent ?? '').not.toContain('[object Object]');
1162
+ },
1163
+ };
1164
+
568
1165
  export const DependantsResetWhenParentChanges: Story = {
1166
+ parameters: {
1167
+ docs: {
1168
+ description: {
1169
+ story:
1170
+ 'Renders FormEngine with two dependent options plus two has-dependents parents. Changing the parent\'s value clears every dependent\'s value while leaving the unrelated sibling untouched.',
1171
+ },
1172
+ },
1173
+ },
569
1174
  args: {
570
1175
  minColumnWidth: '300px',
571
1176
  options: {
@@ -642,6 +1247,14 @@ export const DependantsResetWhenParentChanges: Story = {
642
1247
  };
643
1248
 
644
1249
  export const ValueIsFixedWhenDefaultValueDoesNotMatchAndReadOnlyIsTrue: Story = {
1250
+ parameters: {
1251
+ docs: {
1252
+ description: {
1253
+ story:
1254
+ 'Renders FormEngine with a read-only option whose stored value differs from its default_value — the value is auto-corrected to the default at mount and the wrong value never renders.',
1255
+ },
1256
+ },
1257
+ },
645
1258
  args: {
646
1259
  minColumnWidth: '300px',
647
1260
  options: {
@@ -685,6 +1298,14 @@ export const ValueIsFixedWhenDefaultValueDoesNotMatchAndReadOnlyIsTrue: Story =
685
1298
  };
686
1299
 
687
1300
  export const DoesNotCauseInfiniteRerenders: Story = {
1301
+ parameters: {
1302
+ docs: {
1303
+ description: {
1304
+ story:
1305
+ 'Renders FormEngine with a refetch-triggering parent and a list-of-hash dependent — the schema mounts cleanly and adding a new list item does not cause the form to re-render infinitely.',
1306
+ },
1307
+ },
1308
+ },
688
1309
  args: {
689
1310
  minColumnWidth: '300px',
690
1311
  options: {
@@ -745,6 +1366,14 @@ export const DoesNotCauseInfiniteRerenders: Story = {
745
1366
  };
746
1367
 
747
1368
  export const AllowedValuesOptionWithTemplateValueShowsWarning: Story = {
1369
+ parameters: {
1370
+ docs: {
1371
+ description: {
1372
+ story:
1373
+ 'Renders FormEngine with an allowed_values option that holds a template value ($local:test) instead of one of the allowed values — the option shows a warning that the template value is outside the allowed set.',
1374
+ },
1375
+ },
1376
+ },
748
1377
  args: {
749
1378
  minColumnWidth: '300px',
750
1379
  options: {
@@ -787,6 +1416,12 @@ export const AllowedValuesOptionWithTemplateValueShowsWarning: Story = {
787
1416
  export const OnValidityChange: Story = {
788
1417
  // chromatic off: async validity-callback timing.
789
1418
  parameters: {
1419
+ docs: {
1420
+ description: {
1421
+ story:
1422
+ 'Renders FormEngine wired to an onValidityChange callback — the callback fires with per-field validity data as the overall form validity changes.',
1423
+ },
1424
+ },
790
1425
  chromatic: { disable: true },
791
1426
  },
792
1427
  args: {
@@ -963,7 +1598,7 @@ const CompactSchema: Record<string, TCompactField> = {
963
1598
  };
964
1599
 
965
1600
  // `description` is intentionally left empty so the required-but-unset state is
966
- // visible (a "Required not set" row and an invalid-field message).
1601
+ // visible (a "— Required" row and an invalid-field message).
967
1602
  const CompactValue: IOptions = {
968
1603
  name: { type: 'string', value: 'order-fulfilment' },
969
1604
  lang: { type: 'string', value: 'python' },
@@ -998,6 +1633,32 @@ const clickFieldsMenuItem = async (text: string) => {
998
1633
  await fireEvent.click(item as Element);
999
1634
  };
1000
1635
 
1636
+ // The Optional status box now holds every not-yet-added field AND any added-but-
1637
+ // empty optional field, so it starts COLLAPSED (and ReqorePanel unmounts collapsed
1638
+ // content). Tests that assert on / interact with optional fields call this to open
1639
+ // it. The whole panel title bar toggles the collapse.
1640
+ const _expandOptionalBox = async () => {
1641
+ const findBox = () =>
1642
+ Array.from(document.querySelectorAll('.options-readfirst-group')).find((panel) =>
1643
+ panel.querySelector('.reqore-panel-title')?.textContent?.includes('Optional')
1644
+ );
1645
+ let box: Element | undefined;
1646
+ await waitFor(
1647
+ () => {
1648
+ box = findBox();
1649
+ expect(box).toBeTruthy();
1650
+ },
1651
+ { timeout: 10000 }
1652
+ );
1653
+ // No `.reqore-panel-content` ⇒ the box is collapsed (content unmounted) — open it.
1654
+ if (box && !box.querySelector('.reqore-panel-content')) {
1655
+ await fireEvent.click(box.querySelector('.reqore-panel-title') as HTMLElement);
1656
+ await waitFor(() => expect(box!.querySelector('.reqore-panel-content')).toBeTruthy(), {
1657
+ timeout: 10000,
1658
+ });
1659
+ }
1660
+ };
1661
+
1001
1662
  // CompactSchema plus one optional (non-preselected) field, to exercise the
1002
1663
  // "Fields" menu add / select-all / reset actions.
1003
1664
  const CompactFieldsMenuSchema: Record<string, TCompactField> = {
@@ -1011,26 +1672,156 @@ const CompactFieldsMenuSchema: Record<string, TCompactField> = {
1011
1672
  },
1012
1673
  };
1013
1674
 
1014
- export const Compact: Story = {
1675
+ export const Compact: Story = {
1676
+ parameters: {
1677
+ docs: {
1678
+ description: {
1679
+ story:
1680
+ 'Renders FormEngine in compact mode over the CompactSchema fixture with groups — options collapse to read-first rows grouped under labelled group headers, with formatted value summaries per row.',
1681
+ },
1682
+ },
1683
+ },
1684
+ args: {
1685
+ compact: true,
1686
+ minColumnWidth: '300px',
1687
+ options: CompactSchema,
1688
+ value: CompactValue,
1689
+ groups: CompactGroups,
1690
+ },
1691
+ play: async () => {
1692
+ // Groups render with their display metadata; rows show formatted values.
1693
+ await _testsWaitForText('Identity and core settings');
1694
+ // Regression: `general` is a REAL consumer-defined group here (it's in
1695
+ // CompactGroups, and `description`/`tags` set `group: 'general'`), so its
1696
+ // sub-label MUST render. It must NOT be suppressed as the synthetic "no
1697
+ // group" catch-all — doing so visually merged its rows (e.g. Tags) into the
1698
+ // group above them.
1699
+ await _testsWaitForText('General');
1700
+ await _testsWaitForText('order-fulfilment');
1701
+ await _testsWaitForText('orders, batch');
1702
+ await _testsWaitForText('Yes');
1703
+ await _testsWaitForText('—');
1704
+ },
1705
+ };
1706
+
1707
+ // `name` gains a long-form `desc` so the `?` help affordance has something to
1708
+ // open — the rest of the compact fixture is unchanged.
1709
+ const CompactHelpSchema: Record<string, TCompactField> = {
1710
+ ...CompactSchema,
1711
+ name: {
1712
+ ...CompactSchema.name,
1713
+ desc: 'The unique identifier for this interface. It is used in URLs, logs and cross-references, so it cannot be changed once the interface is deployed.',
1714
+ },
1715
+ };
1716
+
1717
+ export const CompactFocusedEditingInline: Story = {
1718
+ parameters: {
1719
+ docs: {
1720
+ description: {
1721
+ story:
1722
+ 'Renders the compact form, opens the Name row for inline editing and picks "Edit fullscreen" from its More menu — the Focused Editing modal opens over that single scalar field with its long description above the editor.',
1723
+ },
1724
+ },
1725
+ chromatic: { disable: true },
1726
+ },
1727
+ args: {
1728
+ ...Compact.args,
1729
+ options: CompactHelpSchema,
1730
+ },
1731
+ play: async () => {
1732
+ // Name is a scalar, so it edits INLINE in the row (no expanded card) — the
1733
+ // branch whose More menu used to set the focused state with nothing mounted
1734
+ // to render the modal. (CompactFocusedEditing covers the card branch.)
1735
+ await _testsClickText('order-fulfilment');
1736
+ await _testsWaitForInputValue('order-fulfilment', '.options-readfirst-inline .reqore-textarea');
1737
+ await _testsClickButton({ selector: '.options-readfirst-more' });
1738
+ let fsItem: Element | undefined;
1739
+ await waitFor(
1740
+ () => {
1741
+ fsItem = Array.from(document.querySelectorAll('.reqore-menu-item')).find((element) =>
1742
+ element.textContent?.includes('Edit fullscreen')
1743
+ );
1744
+ expect(fsItem).toBeTruthy();
1745
+ },
1746
+ { timeout: 10000 }
1747
+ );
1748
+ await fireEvent.click(fsItem as Element);
1749
+ // The modal mounts, carrying the field's long description with it.
1750
+ await waitFor(() => expect(document.querySelector('.reqore-modal')).toBeTruthy(), {
1751
+ timeout: 10000,
1752
+ });
1753
+ await _testsWaitForText(/unique identifier for this interface/i);
1754
+ },
1755
+ };
1756
+
1757
+ export const CompactEditingShowsDescription: Story = {
1758
+ parameters: {
1759
+ docs: {
1760
+ description: {
1761
+ story:
1762
+ "Renders the compact form with the descriptions toggle off — a collapsed row hides its short description, and opening the row for editing reveals it under the field name along with the `?` help affordance.",
1763
+ },
1764
+ },
1765
+ },
1766
+ args: {
1767
+ ...Compact.args,
1768
+ options: CompactHelpSchema,
1769
+ },
1770
+ play: async () => {
1771
+ // Collapsed: the global descriptions toggle is off, so no short_desc on the
1772
+ // read row (the label carries it as a title attribute only).
1773
+ await _testsWaitForText('order-fulfilment');
1774
+ await _testsWaitForTextToNotExist('Unique identifier for this interface');
1775
+ // Open it: the short description appears without the user clicking anything…
1776
+ await _testsClickText('order-fulfilment');
1777
+ await _testsWaitForInputValue('order-fulfilment', '.options-readfirst-inline .reqore-textarea');
1778
+ await _testsWaitForText('Unique identifier for this interface');
1779
+ // …and the `?` (long-form help) stays reachable while editing.
1780
+ await waitFor(() =>
1781
+ expect(
1782
+ document.querySelector('.options-readfirst-inline .options-readfirst-help')
1783
+ ).toBeTruthy()
1784
+ );
1785
+ },
1786
+ };
1787
+
1788
+ export const CompactWithPanelProps: Story = {
1789
+ parameters: {
1790
+ docs: {
1791
+ description: {
1792
+ story:
1793
+ "Renders the Compact fixture with `compactPanelProps` — the read-first form's outer ReqorePanel is dressed from the outside with a label, icon, intent and an extra header action. The engine's own toolbar survives: `actions` append after it and `contentStyle` merges over the engine's flex-column layout instead of replacing either.",
1794
+ },
1795
+ },
1796
+ },
1015
1797
  args: {
1016
- compact: true,
1017
- minColumnWidth: '300px',
1018
- options: CompactSchema,
1019
- value: CompactValue,
1020
- groups: CompactGroups,
1798
+ ...Compact.args,
1799
+ compactPanelProps: {
1800
+ label: 'Connection settings',
1801
+ icon: 'Settings3Line',
1802
+ intent: 'info',
1803
+ actions: [{ label: 'Docs', icon: 'BookLine', responsive: false }],
1804
+ },
1021
1805
  },
1022
1806
  play: async () => {
1023
- // Groups render with their display metadata; rows show formatted values.
1024
- await _testsWaitForText('Identity and core settings');
1807
+ // The outside-supplied panel chrome renders…
1808
+ await _testsWaitForText('Connection settings');
1809
+ await _testsWaitForText('Docs');
1810
+ // …and the engine's own toolbar + rows are untouched by it.
1025
1811
  await _testsWaitForText('order-fulfilment');
1026
- await _testsWaitForText('orders, batch');
1027
- await _testsWaitForText('Yes');
1028
- await _testsWaitForText('Required — not set');
1029
1812
  },
1030
1813
  };
1031
1814
 
1032
1815
  export const CompactReadOnly: Story = {
1033
- parameters: { chromatic: { disable: true } },
1816
+ parameters: {
1817
+ docs: {
1818
+ description: {
1819
+ story:
1820
+ 'Renders the Compact fixture with readOnly enabled — the Draft/Ready progress badge is hidden and rows open in view (non-editable) mode.',
1821
+ },
1822
+ },
1823
+ chromatic: { disable: true },
1824
+ },
1034
1825
  args: {
1035
1826
  ...Compact.args,
1036
1827
  readOnly: true,
@@ -1039,15 +1830,24 @@ export const CompactReadOnly: Story = {
1039
1830
  await _testsWaitForText('order-fulfilment');
1040
1831
  // Read-only hides the Draft/Ready badge (the meter itself stays)…
1041
1832
  await _testsWaitForTextToNotExist('Draft');
1042
- // …and rows open in view mode: Close instead of Done, then collapse back.
1833
+ // …and rows open in view mode: the card's done (✓/close) button collapses
1834
+ // back. The button is icon-only now, so assert it by class, not text.
1043
1835
  await _testsClickText('order-fulfilment');
1044
- await _testsWaitForText('Close');
1836
+ await waitFor(() => expect(document.querySelector('.options-readfirst-done')).toBeTruthy());
1045
1837
  await _testsClickButton({ selector: '.options-readfirst-done' });
1046
- await _testsWaitForTextToNotExist('Close');
1838
+ await waitFor(() => expect(document.querySelector('.options-readfirst-done')).toBeNull());
1047
1839
  },
1048
1840
  };
1049
1841
 
1050
1842
  export const CompactEmpty: Story = {
1843
+ parameters: {
1844
+ docs: {
1845
+ description: {
1846
+ story:
1847
+ 'Renders the Compact fixture with no value — all six empty fields render a dash placeholder and the four optional fields sit in the collapsed Optional box.',
1848
+ },
1849
+ },
1850
+ },
1051
1851
  args: {
1052
1852
  compact: true,
1053
1853
  minColumnWidth: '300px',
@@ -1056,16 +1856,27 @@ export const CompactEmpty: Story = {
1056
1856
  groups: CompactGroups,
1057
1857
  },
1058
1858
  play: async () => {
1859
+ // The four empty OPTIONAL fields live in the (collapsed) Optional box — open
1860
+ // it so all six empty fields are on screen.
1861
+ await _expandOptionalBox();
1059
1862
  // Both required fields read as unset; the four optional ones as "Not set".
1060
- await _testsWaitForTextsCount('Required not set', undefined, 2);
1061
- await _testsWaitForTextsCount('Not set', undefined, 4);
1863
+ // All six empty fields read as a calm dash (the red asterisk marks required).
1864
+ await _testsWaitForTextsCount('', undefined, 6);
1062
1865
  },
1063
1866
  };
1064
1867
 
1065
1868
  // Compact mode on the EXACT shared fixture behind `Basic` — every option and
1066
1869
  // state the classic layout exercises.
1067
1870
  export const CompactBasic: Story = {
1068
- parameters: { chromatic: { disable: true } },
1871
+ parameters: {
1872
+ docs: {
1873
+ description: {
1874
+ story:
1875
+ 'Renders FormEngine in compact mode over the full Basic fixture — every value renders in its read-first form (templates by name, colours as hex, hashes as field-count summaries), disabled and dependency-locked rows stay non-interactive, and the dependency lock\'s popover navigates to blockers.',
1876
+ },
1877
+ },
1878
+ chromatic: { disable: true },
1879
+ },
1069
1880
  args: {
1070
1881
  compact: true,
1071
1882
  minColumnWidth: '300px',
@@ -1076,6 +1887,9 @@ export const CompactBasic: Story = {
1076
1887
  // Unresolved required/invalid fields → the header shows the Draft badge
1077
1888
  // (the IDE restyled-hero convention).
1078
1889
  await _testsWaitForText('Draft');
1890
+ // Several asserted/clicked fields (Disabled option, …) are empty optionals in
1891
+ // the collapsed Optional box — open it so they're on screen.
1892
+ await _expandOptionalBox();
1079
1893
  // Values resolve in read-first rows: a template shows its display name (from
1080
1894
  // the supplied templates list), colour as hex, hash as a field-count summary.
1081
1895
  await _testsWaitForText('Test (local)');
@@ -1211,7 +2025,15 @@ export const CompactBasic: Story = {
1211
2025
  // ($-token + resolved name). Regression cover for the review note "this should
1212
2026
  // show as a readonly richtext or a readonly template picker".
1213
2027
  export const CompactReadOnlyRichText: Story = {
1214
- parameters: { chromatic: { disable: true } },
2028
+ parameters: {
2029
+ docs: {
2030
+ description: {
2031
+ story:
2032
+ 'Renders the CompactBasic fixture with readOnly enabled — opening the Rich Text row shows a non-editable Slate surface, and the Template row renders as a read-only template-picker chip showing the resolved template name (never the raw $local reference).',
2033
+ },
2034
+ },
2035
+ chromatic: { disable: true },
2036
+ },
1215
2037
  args: {
1216
2038
  ...CompactBasic.args,
1217
2039
  readOnly: true,
@@ -1284,6 +2106,14 @@ const ORDER_STATE_SAMPLE = {
1284
2106
  // Hash rows render the IDE workflow-orders `StructuredDataView` under the
1285
2107
  // fade/"Show more" wrapper; doubles as the raw-vs-envelope data contrast.
1286
2108
  export const CompactHashStructuredView: Story = {
2109
+ parameters: {
2110
+ docs: {
2111
+ description: {
2112
+ story:
2113
+ 'Renders CompactBasic with an extra orderState hash option holding a raw payload — the hash row uses the StructuredDataView tree renderer with type-aware value cells; clicking a value chip opens the hash editor.',
2114
+ },
2115
+ },
2116
+ },
1287
2117
  args: {
1288
2118
  ...CompactBasic.args,
1289
2119
  options: {
@@ -1382,7 +2212,15 @@ export const CompactHashStructuredView: Story = {
1382
2212
  // grows a Save/Discard bar, Save emits `onCommit` (gated on validity), and
1383
2213
  // every staged edit still emits `onChange` flagged `meta.draft`.
1384
2214
  export const CompactBatchedCommit: Story = {
1385
- parameters: { chromatic: { disable: true } },
2215
+ parameters: {
2216
+ docs: {
2217
+ description: {
2218
+ story:
2219
+ 'Renders a valid form in commitMode=\'batched\'. Staging an edit adds the Draft chip and \'unsaved changes\' bar without committing; Save fires onCommit and clears the chips; Discard reverts the staged edit.',
2220
+ },
2221
+ },
2222
+ chromatic: { disable: true },
2223
+ },
1386
2224
  args: {
1387
2225
  compact: true,
1388
2226
  commitMode: 'batched',
@@ -1448,6 +2286,14 @@ export const CompactBatchedCommit: Story = {
1448
2286
 
1449
2287
  // While any field is invalid, the bar shows but Save refuses to commit.
1450
2288
  export const CompactBatchedCommitInvalid: Story = {
2289
+ parameters: {
2290
+ docs: {
2291
+ description: {
2292
+ story:
2293
+ 'Renders an invalid form in commitMode=\'batched\' — staging an edit shows the unsaved-changes bar but Save is disabled and onCommit never fires.',
2294
+ },
2295
+ },
2296
+ },
1451
2297
  args: {
1452
2298
  compact: true,
1453
2299
  commitMode: 'batched',
@@ -1483,6 +2329,14 @@ export const CompactBatchedCommitInvalid: Story = {
1483
2329
  // `sensitive`: the read row masks the value (and its hover title) — the secret
1484
2330
  // never renders as page text, in read or edit state.
1485
2331
  export const CompactSensitive: Story = {
2332
+ parameters: {
2333
+ docs: {
2334
+ description: {
2335
+ story:
2336
+ 'Renders a compact form with a sensitive: true API-token field — the read row masks the value (and its hover title) and the edit input renders as type=password.',
2337
+ },
2338
+ },
2339
+ },
1486
2340
  args: {
1487
2341
  compact: true,
1488
2342
  minColumnWidth: '300px',
@@ -1522,6 +2376,14 @@ export const CompactSensitive: Story = {
1522
2376
  // `rules: ['valid_identifier']` flows from the schema into validation: a bad
1523
2377
  // identifier marks the form invalid (banner + Draft badge).
1524
2378
  export const CompactValidIdentifierRule: Story = {
2379
+ parameters: {
2380
+ docs: {
2381
+ description: {
2382
+ story:
2383
+ 'Renders a compact form with an option that carries a valid-identifier validation rule — invalid input surfaces the identifier-format error inline.',
2384
+ },
2385
+ },
2386
+ },
1525
2387
  args: {
1526
2388
  compact: true,
1527
2389
  minColumnWidth: '300px',
@@ -1539,17 +2401,24 @@ export const CompactValidIdentifierRule: Story = {
1539
2401
  play: async () => {
1540
2402
  await _testsWaitForText('Variable name');
1541
2403
  await _testsWaitForText('1-bad-identifier');
1542
- // The rules-driven validation marks the form as needing attention.
2404
+ // The rules-driven validation marks the form as needing attention — the
2405
+ // dedicated "Needs attention" box (and the header link) signal it.
1543
2406
  await _testsWaitForText('Draft');
1544
- await _testsWaitForText(
1545
- 'A field is not valid and requires attention. Click here to only show invalid fields.'
1546
- );
2407
+ await _testsWaitForText('Needs attention');
1547
2408
  },
1548
2409
  };
1549
2410
 
1550
2411
  // Operators (filter/mapper-style forms): the `operators` prop renders the
1551
2412
  // operator selector + the WHERE/IS summary in the card editor.
1552
2413
  export const CompactOperators: Story = {
2414
+ parameters: {
2415
+ docs: {
2416
+ description: {
2417
+ story:
2418
+ 'Renders a compact form with per-option operator support — each row shows the operator select alongside the value and the WHERE/IS summary tags.',
2419
+ },
2420
+ },
2421
+ },
1553
2422
  args: {
1554
2423
  compact: true,
1555
2424
  minColumnWidth: '300px',
@@ -1593,7 +2462,15 @@ export const CompactOperators: Story = {
1593
2462
  // focusedEditing in compact: the card's fullscreen affordance opens the same
1594
2463
  // focused-editing modal the classic layout has, with the field's descriptions.
1595
2464
  export const CompactFocusedEditing: Story = {
1596
- parameters: { chromatic: { disable: true } },
2465
+ parameters: {
2466
+ docs: {
2467
+ description: {
2468
+ story:
2469
+ 'Renders the compact form, opens a row and switches to focused-editing mode — the row expands into a modal-style editing surface.',
2470
+ },
2471
+ },
2472
+ chromatic: { disable: true },
2473
+ },
1597
2474
  args: {
1598
2475
  compact: true,
1599
2476
  minColumnWidth: '300px',
@@ -1608,7 +2485,19 @@ export const CompactFocusedEditing: Story = {
1608
2485
  await waitFor(() => expect(document.querySelector('.options-readfirst-card')).toBeTruthy(), {
1609
2486
  timeout: 10000,
1610
2487
  });
1611
- await _testsClickButton({ selector: '.options-readfirst-fullscreen' });
2488
+ // Fullscreen now lives in the card's "More" (⋮) menu, before the Done ✓.
2489
+ await _testsClickButton({ selector: '.options-readfirst-more' });
2490
+ let fsItem: Element | undefined;
2491
+ await waitFor(
2492
+ () => {
2493
+ fsItem = Array.from(document.querySelectorAll('.reqore-menu-item')).find((element) =>
2494
+ element.textContent?.includes('Edit fullscreen')
2495
+ );
2496
+ expect(fsItem).toBeTruthy();
2497
+ },
2498
+ { timeout: 10000 }
2499
+ );
2500
+ await fireEvent.click(fsItem as Element);
1612
2501
  await waitFor(() => expect(document.querySelector('.reqore-modal')).toBeTruthy(), {
1613
2502
  timeout: 10000,
1614
2503
  });
@@ -1619,7 +2508,15 @@ export const CompactFocusedEditing: Story = {
1619
2508
  // multi-select editor in the card.
1620
2509
  export const CompactMultiSelectEditing: Story = {
1621
2510
  // chromatic off: ends with an open multi-select editor card (live editor state).
1622
- parameters: { chromatic: { disable: true } },
2511
+ parameters: {
2512
+ docs: {
2513
+ description: {
2514
+ story:
2515
+ 'Renders a compact form with a multi-select option — editing the row exposes the chip picker and multiple values can be added and removed.',
2516
+ },
2517
+ },
2518
+ chromatic: { disable: true },
2519
+ },
1623
2520
  args: {
1624
2521
  compact: true,
1625
2522
  minColumnWidth: '300px',
@@ -1654,7 +2551,15 @@ export const CompactMultiSelectEditing: Story = {
1654
2551
 
1655
2552
  // Field-level `sort` orders compact rows (schema declared out of order).
1656
2553
  export const CompactSortOrder: Story = {
1657
- parameters: { chromatic: { disable: true } },
2554
+ parameters: {
2555
+ docs: {
2556
+ description: {
2557
+ story:
2558
+ 'Renders a compact form whose options carry sort ordinals — the rows render in sort order rather than schema-declaration order.',
2559
+ },
2560
+ },
2561
+ chromatic: { disable: true },
2562
+ },
1658
2563
  args: {
1659
2564
  compact: true,
1660
2565
  minColumnWidth: '300px',
@@ -1684,6 +2589,9 @@ export const CompactSortOrder: Story = {
1684
2589
  value: {} as IOptions,
1685
2590
  },
1686
2591
  play: async () => {
2592
+ // All three fields are empty + optional, so they sit in the (collapsed)
2593
+ // Optional box — open it to read their order.
2594
+ await _expandOptionalBox();
1687
2595
  await _testsWaitForText('First');
1688
2596
  const order = Array.from(document.querySelectorAll('.readfirst-row[data-field]')).map(
1689
2597
  (element) => element.getAttribute('data-field')
@@ -1693,7 +2601,15 @@ export const CompactSortOrder: Story = {
1693
2601
  };
1694
2602
 
1695
2603
  export const CompactReadFirstEditing: Story = {
1696
- parameters: { chromatic: { disable: true } },
2604
+ parameters: {
2605
+ docs: {
2606
+ description: {
2607
+ story:
2608
+ 'Renders the compact form and opens a row for editing — the row transitions from the read-first summary to the inline or card editor.',
2609
+ },
2610
+ },
2611
+ chromatic: { disable: true },
2612
+ },
1697
2613
  args: {
1698
2614
  compact: true,
1699
2615
  minColumnWidth: '300px',
@@ -1709,7 +2625,7 @@ export const CompactReadFirstEditing: Story = {
1709
2625
  await _testsWaitForText('Yes');
1710
2626
  await _testsWaitForText('Python');
1711
2627
  // The required-but-empty field shows its placeholder instead of an editor.
1712
- await _testsWaitForText('Required not set');
2628
+ await _testsWaitForText('—');
1713
2629
  // No field editor (textarea) is mounted while everything is collapsed.
1714
2630
  await expect(document.querySelectorAll('.reqore-textarea')).toHaveLength(0);
1715
2631
 
@@ -1739,8 +2655,79 @@ export const CompactReadFirstEditing: Story = {
1739
2655
  },
1740
2656
  };
1741
2657
 
2658
+ // Following a field across panels: filling an empty optional field moves it from
2659
+ // the Optional box to Set, and the engine scrolls to + flashes its new row so it's
2660
+ // easy to keep track of. The flash is the observable signal that the panel-change
2661
+ // locate fired (the scroll itself, scrollIntoView, isn't assertable in the runner).
2662
+ export const CompactPanelChangeScroll: Story = {
2663
+ parameters: {
2664
+ docs: {
2665
+ description: {
2666
+ story:
2667
+ 'Renders a compact form inside a scrollable panel — opening a row keeps the panel\'s scroll position pinned rather than jumping to the top.',
2668
+ },
2669
+ },
2670
+ chromatic: { disable: true },
2671
+ },
2672
+ args: {
2673
+ compact: true,
2674
+ minColumnWidth: '300px',
2675
+ options: {
2676
+ req: {
2677
+ type: 'string',
2678
+ ui_type: 'string',
2679
+ display_name: 'Req',
2680
+ required: true,
2681
+ preselected: true,
2682
+ },
2683
+ opt: { type: 'string', ui_type: 'string', display_name: 'Opt', preselected: true },
2684
+ } as IOptionsSchema,
2685
+ value: {} as IOptions,
2686
+ },
2687
+ play: async () => {
2688
+ await _testsWaitForText('Req');
2689
+ // 'opt' is an empty optional → the (collapsed) Optional box. Open it, fill the
2690
+ // field, collapse — it jumps to Set.
2691
+ await _expandOptionalBox();
2692
+ await _testsClickText('Opt');
2693
+ await _testsChangeStringField({
2694
+ selector: '.options-readfirst-inline .reqore-textarea',
2695
+ value: 'hello',
2696
+ });
2697
+ await sleep(300);
2698
+ await _testsClickButton({ selector: '.options-readfirst-done' });
2699
+ // It now lives in the Set box…
2700
+ await waitFor(
2701
+ () =>
2702
+ expect(
2703
+ document
2704
+ .querySelector('.readfirst-row[data-field="opt"]')
2705
+ ?.closest('.options-readfirst-group')
2706
+ ?.querySelector('.reqore-panel-title')?.textContent
2707
+ ).toContain('Set'),
2708
+ { timeout: 5000 }
2709
+ );
2710
+ // …and flashed, signalling the engine located/scrolled to its new panel.
2711
+ await waitFor(
2712
+ () =>
2713
+ expect(
2714
+ document.querySelector('.readfirst-row[data-field="opt"]')?.className
2715
+ ).toContain('readfirst-row-flash'),
2716
+ { timeout: 4000 }
2717
+ );
2718
+ },
2719
+ };
2720
+
1742
2721
  export const CompactRequiredOnlyAndSearch: Story = {
1743
- parameters: { chromatic: { disable: true } },
2722
+ parameters: {
2723
+ docs: {
2724
+ description: {
2725
+ story:
2726
+ 'Renders a compact form with the required-only filter and the search input enabled — filtering by requirement and typing a query narrows the visible rows.',
2727
+ },
2728
+ },
2729
+ chromatic: { disable: true },
2730
+ },
1744
2731
  args: {
1745
2732
  compact: true,
1746
2733
  minColumnWidth: '300px',
@@ -1777,7 +2764,15 @@ export const CompactRequiredOnlyAndSearch: Story = {
1777
2764
  };
1778
2765
 
1779
2766
  export const CompactFieldsMenu: Story = {
1780
- parameters: { chromatic: { disable: true } },
2767
+ parameters: {
2768
+ docs: {
2769
+ description: {
2770
+ story:
2771
+ 'Renders a compact form and opens the Fields menu — the menu exposes show-types, show-descriptions and required-only toggles.',
2772
+ },
2773
+ },
2774
+ chromatic: { disable: true },
2775
+ },
1781
2776
  args: {
1782
2777
  compact: true,
1783
2778
  minColumnWidth: '300px',
@@ -1787,34 +2782,76 @@ export const CompactFieldsMenu: Story = {
1787
2782
  },
1788
2783
  play: async () => {
1789
2784
  await _testsWaitForText('Tags');
1790
- // 'Notes' is optional and unset, so it is not listed as a row yet.
2785
+ // 'Notes' is optional + unset it lives (collapsed) in the Optional box, so
2786
+ // it isn't in the DOM yet.
1791
2787
  await _testsWaitForTextToNotExist('Notes');
1792
2788
 
1793
- // "Select all" adds every optional field Notes now appears as a row.
2789
+ // "Select all" adds every optional field. Reveal the (collapsed) Optional box
2790
+ // — Notes is now an ADDED row (the normal variant, not the hidden/addable one).
1794
2791
  await clickFieldsMenuItem('Select all');
2792
+ await _expandOptionalBox();
1795
2793
  await _testsWaitForText('Notes');
2794
+ await waitFor(() =>
2795
+ expect(
2796
+ document.querySelector('.readfirst-row[data-field="notes"]:not(.readfirst-row-hidden)')
2797
+ ).toBeTruthy()
2798
+ );
1796
2799
 
1797
- // "Default fields" drops the user-added optional fields — Notes is removed.
2800
+ // "Default fields" drops the user-added optional fields — Notes reverts to a
2801
+ // HIDDEN (addable) row in the still-open Optional box (it's always browsable
2802
+ // now, just not added).
1798
2803
  await clickFieldsMenuItem('Default fields');
1799
- await _testsWaitForTextToNotExist('Notes');
2804
+ await waitFor(() =>
2805
+ expect(document.querySelector('.readfirst-row-hidden[data-field="notes"]')).toBeTruthy()
2806
+ );
1800
2807
 
1801
- // The per-row delete affordance: re-add Notes, then remove it via its row's
1802
- // delete button → the confirm modal → Confirm.
2808
+ // The delete affordance now lives in the expanded editor's "More" (⋮) menu:
2809
+ // re-add Notes, open it, then Remove field via More → the confirm modal →
2810
+ // Confirm.
1803
2811
  await clickFieldsMenuItem('Select all');
1804
2812
  await _testsWaitForText('Notes');
1805
- await fireEvent.click(
1806
- document.querySelector('.readfirst-row[data-field="notes"] .readfirst-action') as HTMLElement
2813
+ await _testsClickText('Notes');
2814
+ // Only Notes is expanded, so the single More () menu in the DOM is its own.
2815
+ // (ReqoreDropdown's trigger isn't a DOM descendant of the row, so don't scope
2816
+ // the selector to [data-field].)
2817
+ await waitFor(
2818
+ () => expect(document.querySelector('.options-readfirst-more')).toBeTruthy(),
2819
+ { timeout: 10000 }
2820
+ );
2821
+ await _testsClickButton({ selector: '.options-readfirst-more' });
2822
+ let removeItem: Element | undefined;
2823
+ await waitFor(
2824
+ () => {
2825
+ removeItem = Array.from(document.querySelectorAll('.reqore-menu-item')).find((element) =>
2826
+ element.textContent?.includes('Remove field')
2827
+ );
2828
+ expect(removeItem).toBeTruthy();
2829
+ },
2830
+ { timeout: 10000 }
1807
2831
  );
1808
- await _testsWaitForText('Remove field');
2832
+ await fireEvent.click(removeItem as Element);
1809
2833
  await _testsClickButton({ label: 'Confirm' });
1810
- await _testsWaitForTextToNotExist('Notes');
2834
+ // Removing the field reverts it to a HIDDEN (addable) row in the still-open
2835
+ // Optional box (it's always browsable now, just no longer added) — and its
2836
+ // editor closes.
2837
+ await waitFor(() =>
2838
+ expect(document.querySelector('.readfirst-row-hidden[data-field="notes"]')).toBeTruthy()
2839
+ );
1811
2840
  },
1812
2841
  };
1813
2842
 
1814
2843
  // Toolbar ⓘ: a global toggle that reveals every field's short_desc at once,
1815
2844
  // without opening each row's info panel by hand.
1816
2845
  export const CompactDescriptionsToggle: Story = {
1817
- parameters: { chromatic: { disable: true } },
2846
+ parameters: {
2847
+ docs: {
2848
+ description: {
2849
+ story:
2850
+ 'Renders a compact form and toggles show-descriptions from the Fields menu — every row picks up its short_desc / desc text under the label.',
2851
+ },
2852
+ },
2853
+ chromatic: { disable: true },
2854
+ },
1818
2855
  args: {
1819
2856
  compact: true,
1820
2857
  minColumnWidth: '300px',
@@ -1847,6 +2884,31 @@ export const CompactDescriptionsToggle: Story = {
1847
2884
  // One toggle reveals the short_desc on every field that has one.
1848
2885
  await _testsWaitForText('The server hostname or IP address');
1849
2886
  await _testsWaitForText('TCP port to connect on');
2887
+
2888
+ // Regression: opening a field for INLINE editing must keep its description
2889
+ // visible while the global toggle is on — it used to vanish because the
2890
+ // inline editor's label dropped the short_desc.
2891
+ await _testsClickText('Host');
2892
+ await waitFor(
2893
+ () =>
2894
+ expect(
2895
+ document.querySelector(
2896
+ '.readfirst-row-editing[data-field="host"] .options-readfirst-label-desc'
2897
+ )
2898
+ ).toBeTruthy(),
2899
+ { timeout: 10000 }
2900
+ );
2901
+ await _testsWaitForText('The server hostname or IP address');
2902
+ // Collapse back to the read row (Done) before toggling descriptions off.
2903
+ await _testsClickButton({ selector: '[data-field="host"] .options-readfirst-done' });
2904
+ await waitFor(
2905
+ () =>
2906
+ expect(
2907
+ document.querySelector('.readfirst-row-editing[data-field="host"]')
2908
+ ).toBeFalsy(),
2909
+ { timeout: 10000 }
2910
+ );
2911
+
1850
2912
  // Toggling off hides them again.
1851
2913
  await _testsClickButton({ selector: '.options-readfirst-descriptions' });
1852
2914
  await _testsWaitForTextToNotExist('The server hostname or IP address');
@@ -1854,7 +2916,15 @@ export const CompactDescriptionsToggle: Story = {
1854
2916
  };
1855
2917
 
1856
2918
  export const CompactSearchHidden: Story = {
1857
- parameters: { chromatic: { disable: true } },
2919
+ parameters: {
2920
+ docs: {
2921
+ description: {
2922
+ story:
2923
+ 'Renders a compact form with searchHidden set — the search input and header search action are hidden from the toolbar.',
2924
+ },
2925
+ },
2926
+ chromatic: { disable: true },
2927
+ },
1858
2928
  args: {
1859
2929
  compact: true,
1860
2930
  minColumnWidth: '300px',
@@ -1874,7 +2944,9 @@ export const CompactSearchHidden: Story = {
1874
2944
  value: 'notes',
1875
2945
  });
1876
2946
  await _testsWaitForText('Notes');
1877
- await _testsWaitForText('Not in form — add');
2947
+ await waitFor(() =>
2948
+ expect(document.querySelector('.readfirst-row-hidden[data-field="notes"]')).toBeTruthy()
2949
+ );
1878
2950
 
1879
2951
  // Rows are keyboard-operable (role=button + Enter): focusing the hidden row
1880
2952
  // and pressing Enter adds the field and opens its inline editor.
@@ -1951,7 +3023,15 @@ const OAuth2ScopesSchema = {
1951
3023
  } as any;
1952
3024
 
1953
3025
  export const CompactListYamlField: Story = {
1954
- parameters: { chromatic: { disable: true } },
3026
+ parameters: {
3027
+ docs: {
3028
+ description: {
3029
+ story:
3030
+ 'Renders a compact form with a list-of-YAML option — the row summary shows the item count and opening the row exposes the YAML editor.',
3031
+ },
3032
+ },
3033
+ chromatic: { disable: true },
3034
+ },
1955
3035
  args: {
1956
3036
  compact: true,
1957
3037
  minColumnWidth: '300px',
@@ -1994,7 +3074,15 @@ export const CompactListYamlField: Story = {
1994
3074
  // value column became `minmax(0, 1fr)` + `min-width: 0`; plus the sticky
1995
3075
  // completion + search + Fields toolbar.
1996
3076
  export const CompactOverflowAndStickyHeader: Story = {
1997
- parameters: { chromatic: { disable: true } },
3077
+ parameters: {
3078
+ docs: {
3079
+ description: {
3080
+ story:
3081
+ 'Renders a compact form tall enough to scroll — the group headers stick to the top of the panel as the form scrolls under them.',
3082
+ },
3083
+ },
3084
+ chromatic: { disable: true },
3085
+ },
1998
3086
  // A fixed-height scroll host so the sticky behaviour is observable (and
1999
3087
  // testable) regardless of viewport size.
2000
3088
  decorators: [
@@ -2048,7 +3136,15 @@ export const CompactOverflowAndStickyHeader: Story = {
2048
3136
  // on_change/refetch + has_dependents flow through the same handleValueChange
2049
3137
  // as classic — the read-first editor must fire and reset the same way.
2050
3138
  export const CompactOnChangeAndDependents: Story = {
2051
- parameters: { chromatic: { disable: true } },
3139
+ parameters: {
3140
+ docs: {
3141
+ description: {
3142
+ story:
3143
+ 'Renders a compact form with dependency chains — editing a parent option resets its dependents and the dependent rows re-render in their fresh state.',
3144
+ },
3145
+ },
3146
+ chromatic: { disable: true },
3147
+ },
2052
3148
  args: {
2053
3149
  compact: true,
2054
3150
  minColumnWidth: '300px',
@@ -2105,7 +3201,15 @@ export const CompactOnChangeAndDependents: Story = {
2105
3201
  };
2106
3202
 
2107
3203
  export const CompactRevertAndShowTypes: Story = {
2108
- parameters: { chromatic: { disable: true } },
3204
+ parameters: {
3205
+ docs: {
3206
+ description: {
3207
+ story:
3208
+ 'Renders a compact form and exercises the per-row revert action alongside the show-types Fields menu toggle.',
3209
+ },
3210
+ },
3211
+ chromatic: { disable: true },
3212
+ },
2109
3213
  args: {
2110
3214
  compact: true,
2111
3215
  minColumnWidth: '300px',
@@ -2186,7 +3290,15 @@ const FieldTypeCatalogGroups: Record<string, IFormEngineGroup> = {
2186
3290
  */
2187
3291
  export const CompactExpressions: Story = {
2188
3292
  // chromatic off: ends with the live ExpressionField editor (Text mode) open.
2189
- parameters: { chromatic: { disable: true } },
3293
+ parameters: {
3294
+ docs: {
3295
+ description: {
3296
+ story:
3297
+ 'Renders a compact form with an expression-supporting option — the row opens the ExpressionField shell with the Visual builder and the Visual/Text mode toggle.',
3298
+ },
3299
+ },
3300
+ chromatic: { disable: true },
3301
+ },
2190
3302
  args: {
2191
3303
  name: 'exprForm',
2192
3304
  compact: true,
@@ -2273,6 +3385,57 @@ const langImg = (color: string, letter: string): string =>
2273
3385
  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="${color}"/><text x="16" y="23" font-size="20" fill="white" text-anchor="middle" font-family="sans-serif">${letter}</text></svg>`
2274
3386
  )}`;
2275
3387
 
3388
+ const assertFixedChoiceCanCloseWithoutClearing = async (field: string, expectedLabel: string) => {
3389
+ const cardSelector = `.options-readfirst-card[data-field="${field}"]`;
3390
+ const rowSelector = `.readfirst-row[data-field="${field}"]`;
3391
+
3392
+ let card: HTMLElement | null = null;
3393
+ await waitFor(
3394
+ () => {
3395
+ card = document.querySelector(cardSelector);
3396
+ expect(card).toBeTruthy();
3397
+ },
3398
+ { timeout: 10000 }
3399
+ );
3400
+
3401
+ const clearButton = card!.querySelector(
3402
+ '.options-readfirst-clear[aria-label="Clear value"]'
3403
+ ) as HTMLElement;
3404
+ const closeButton = card!.querySelector(
3405
+ '.options-readfirst-done[aria-label="Close field"]'
3406
+ ) as HTMLElement;
3407
+ await expect(clearButton).toBeInTheDocument();
3408
+ await expect(closeButton).toBeInTheDocument();
3409
+ // Clear is destructive and visually distinct from the adjacent passive
3410
+ // actions. It must not mutate the value until the Reqore confirmation is
3411
+ // accepted; cancelling keeps both the value and expanded editor intact.
3412
+ await expect(getComputedStyle(clearButton).color).not.toBe(getComputedStyle(closeButton).color);
3413
+ await fireEvent.click(clearButton);
3414
+ await waitFor(() => expect(document.querySelector('.reqore-confirmation-modal')).toBeTruthy(), {
3415
+ timeout: 10000,
3416
+ });
3417
+ await expect(document.querySelector('.reqore-confirmation-modal')?.textContent).toContain(
3418
+ 'Clear value'
3419
+ );
3420
+ await _testsClickButton({ label: 'Cancel' });
3421
+ await waitFor(() => expect(document.querySelector('.reqore-confirmation-modal')).toBeNull());
3422
+ await expect(document.querySelector(cardSelector)).toBeTruthy();
3423
+
3424
+ await fireEvent.click(closeButton);
3425
+ await waitFor(() => expect(document.querySelector(cardSelector)).toBeNull(), {
3426
+ timeout: 10000,
3427
+ });
3428
+
3429
+ const row = document.querySelector(rowSelector) as HTMLElement;
3430
+ expect(row?.textContent).toContain(expectedLabel);
3431
+
3432
+ // Re-open so the story's visual snapshot still covers the expanded fixed-choice card.
3433
+ await fireEvent.click(row);
3434
+ await waitFor(() => expect(document.querySelector(cardSelector)).toBeTruthy(), {
3435
+ timeout: 10000,
3436
+ });
3437
+ };
3438
+
2276
3439
  /**
2277
3440
  * Enum field with per-value images (the IDE `language` field shape: `type:
2278
3441
  * 'enum'` + `items: [{ value, title, image }]`). Read-first shows the selected
@@ -2283,7 +3446,15 @@ const langImg = (color: string, letter: string): string =>
2283
3446
  */
2284
3447
  export const CompactEnumWithImages: Story = {
2285
3448
  // chromatic off: ends with the radio editor open in the card.
2286
- parameters: { chromatic: { disable: true } },
3449
+ parameters: {
3450
+ docs: {
3451
+ description: {
3452
+ story:
3453
+ 'Renders a compact form with an enum option whose allowed values carry images — the read row shows the image alongside the label and the picker mounts the images in the collection.',
3454
+ },
3455
+ },
3456
+ chromatic: { disable: true },
3457
+ },
2287
3458
  args: {
2288
3459
  name: 'langForm',
2289
3460
  compact: true,
@@ -2330,6 +3501,7 @@ export const CompactEnumWithImages: Story = {
2330
3501
  },
2331
3502
  { timeout: 10000 }
2332
3503
  );
3504
+ await assertFixedChoiceCanCloseWithoutClearing('lang', 'Qore');
2333
3505
  },
2334
3506
  };
2335
3507
 
@@ -2341,7 +3513,15 @@ export const CompactEnumWithImages: Story = {
2341
3513
  * instead of the language radio.
2342
3514
  */
2343
3515
  export const CompactEnumRichtextValue: Story = {
2344
- parameters: { chromatic: { disable: true } },
3516
+ parameters: {
3517
+ docs: {
3518
+ description: {
3519
+ story:
3520
+ 'Renders a compact form with an enum option whose value is a richtext template — the read row shows the resolved template chip rather than the raw reference.',
3521
+ },
3522
+ },
3523
+ chromatic: { disable: true },
3524
+ },
2345
3525
  args: {
2346
3526
  name: 'langForm',
2347
3527
  compact: true,
@@ -2398,6 +3578,7 @@ export const CompactEnumRichtextValue: Story = {
2398
3578
  },
2399
3579
  { timeout: 10000 }
2400
3580
  );
3581
+ await assertFixedChoiceCanCloseWithoutClearing('lang', 'Qore');
2401
3582
  },
2402
3583
  };
2403
3584
 
@@ -2421,7 +3602,15 @@ const _isRowOpen = (field: string): boolean =>
2421
3602
  * first — the accordion model that keeps the read-first list scannable.
2422
3603
  */
2423
3604
  export const CompactSingleExpand: Story = {
2424
- parameters: { chromatic: { disable: true } },
3605
+ parameters: {
3606
+ docs: {
3607
+ description: {
3608
+ story:
3609
+ 'Renders a compact form with expandMode=\'single\' — opening one row automatically collapses whichever row was open before.',
3610
+ },
3611
+ },
3612
+ chromatic: { disable: true },
3613
+ },
2425
3614
  args: { name: 'expandSingle', compact: true, ...expandModeFixture },
2426
3615
  play: async () => {
2427
3616
  await waitFor(
@@ -2447,7 +3636,15 @@ export const CompactSingleExpand: Story = {
2447
3636
 
2448
3637
  /** `expandMode: 'multi'`: several rows can stay open at once (form-fill flow). */
2449
3638
  export const CompactMultiExpand: Story = {
2450
- parameters: { chromatic: { disable: true } },
3639
+ parameters: {
3640
+ docs: {
3641
+ description: {
3642
+ story:
3643
+ 'Renders a compact form with expandMode=\'multi\' — every opened row stays open until it is explicitly done, so several editors can be on screen at once.',
3644
+ },
3645
+ },
3646
+ chromatic: { disable: true },
3647
+ },
2451
3648
  args: { name: 'expandMulti', compact: true, expandMode: 'multi', ...expandModeFixture },
2452
3649
  play: async () => {
2453
3650
  await waitFor(
@@ -2496,6 +3693,14 @@ const HostProvidedEditor = ({
2496
3693
  );
2497
3694
 
2498
3695
  export const CompactFieldTypes: Story = {
3696
+ parameters: {
3697
+ docs: {
3698
+ description: {
3699
+ story:
3700
+ 'Renders a compact form that exercises the full catalogue of ui_type renderers — every type (string, richtext, hash, list, file, colour, byte-size, cron, connection, enum, etc.) is present with a representative value.',
3701
+ },
3702
+ },
3703
+ },
2499
3704
  args: {
2500
3705
  compact: true,
2501
3706
  minColumnWidth: '300px',
@@ -2924,33 +4129,21 @@ export const CompactFieldTypes: Story = {
2924
4129
  // Descriptions & messages: Tier-1 messages auto-open the row's info panel
2925
4130
  // (visible without interaction); Tier-2 messages wait behind the ⓘ toggle,
2926
4131
  // and a field's short_desc renders UNDER the name when toggled. NB: opening a
2927
- // message panel rebuilds the row's DOM (the info-row wrapper appears) — query
2928
- // fresh nodes per click.
2929
- await _testsWaitForText('This value fails validation upstream.');
2930
- await _testsWaitForText('Deprecated — migrate before 2026-09.');
2931
- const catalogPanel = (field: string) =>
2932
- document.querySelector(
2933
- `.options-readfirst-info-row[data-field="${field}"] .options-readfirst-info-panel`
2934
- );
2935
- await expect(catalogPanel('infoMsgQuiet')).toBeNull();
2936
- await fireEvent.click(
2937
- document.querySelector(
2938
- '.readfirst-row[data-field="infoMsgQuiet"] .options-readfirst-info-slot .options-readfirst-info-toggle'
2939
- ) as HTMLElement
2940
- );
4132
+ // message panel rebuilds the row's DOM (the info-row wrapper appears) — query
4133
+ // fresh nodes per click.
4134
+ await _testsWaitForText('This value fails validation upstream.');
4135
+ await _testsWaitForText('Deprecated — migrate before 2026-09.');
4136
+ // Dedicated schema messages render as panels, visible WITHOUT interaction
4137
+ // (the per-row ⓘ is gone — descriptions are revealed by the global toggle or
4138
+ // by expanding the field).
2941
4139
  await _testsWaitForText('Requests are signed automatically.');
2942
4140
  await _testsWaitForText('Connection verified.');
2943
- // A short_desc-only field has no value-side panel; the reveals the short_desc
2944
- // under the name. desc renders the ? help affordance.
2945
- await expect(catalogPanel('infoShortDesc')).toBeNull();
2946
- await fireEvent.click(
2947
- document.querySelector(
2948
- '.readfirst-row[data-field="infoShortDesc"] .options-readfirst-info-slot .options-readfirst-info-toggle'
2949
- ) as HTMLElement
2950
- );
4141
+ // The global descriptions toggle reveals each field's short_desc under its name.
4142
+ await fireEvent.click(document.querySelector('.options-readfirst-descriptions') as HTMLElement);
2951
4143
  await _testsWaitForText(
2952
4144
  'A one-line summary shown under the field name and in the hover title.'
2953
4145
  );
4146
+ // A field with a long desc still exposes the ? help affordance.
2954
4147
  await expect(
2955
4148
  document.querySelector('.readfirst-row[data-field="infoLongDesc"] .options-readfirst-help')
2956
4149
  ).toBeTruthy();
@@ -2988,7 +4181,10 @@ const _compactExpandAllRows = async () => {
2988
4181
  document.querySelectorAll<HTMLElement>(
2989
4182
  '.readfirst-row:not(.readfirst-row-editing):not(.readfirst-row-disabled):not(.readfirst-row-hidden)'
2990
4183
  )
2991
- );
4184
+ // An arg_schema field opens a NESTED compact sub-form (recursive compact);
4185
+ // its rows live inside the parent's edit card — don't count those as
4186
+ // top-level read rows to expand.
4187
+ ).filter((r) => !r.closest('.options-readfirst-card'));
2992
4188
  // Generous guard: the catalog has ~70 fields.
2993
4189
  for (let guard = 0; guard < 120; guard++) {
2994
4190
  const remaining = readRows();
@@ -3009,7 +4205,15 @@ const _compactExpandAllRows = async () => {
3009
4205
 
3010
4206
  export const CompactFieldTypesEditing: Story = {
3011
4207
  // chromatic off: every catalog editor mounts live (async) — flaky and snapshot-heavy.
3012
- parameters: { chromatic: { disable: true } },
4208
+ parameters: {
4209
+ docs: {
4210
+ description: {
4211
+ story:
4212
+ 'Renders CompactFieldTypes and opens every row — the edit surface for each ui_type mounts and the row-level Clear / built-in clear affordances are wired per input.',
4213
+ },
4214
+ },
4215
+ chromatic: { disable: true },
4216
+ },
3013
4217
  // multi: this story expands every row at once (single-open would collapse them).
3014
4218
  args: { ...CompactFieldTypes.args, expandMode: 'multi' as const },
3015
4219
  play: async () => {
@@ -3036,8 +4240,28 @@ export const CompactFieldTypesEditing: Story = {
3036
4240
  editRow('enabled').querySelector('.options-readfirst-revert')
3037
4241
  ).not.toBeInTheDocument();
3038
4242
 
3039
- // Clear it the value empties, so Clear is replaced by Revert in place.
4243
+ // Clear is destructive: cancelling its Reqore confirmation leaves the
4244
+ // value untouched, while confirming empties it and swaps Clear for Revert.
4245
+ await fireEvent.click(editRow('enabled').querySelector('.options-readfirst-clear')!);
4246
+ await waitFor(() => expect(document.querySelector('.reqore-confirmation-modal')).toBeTruthy(), {
4247
+ timeout: 10000,
4248
+ });
4249
+ await _testsClickButton({ label: 'Cancel' });
4250
+ await waitFor(() => expect(document.querySelector('.reqore-confirmation-modal')).toBeNull());
4251
+ await expect(editRow('enabled').querySelector('.options-readfirst-clear')).toBeInTheDocument();
4252
+
3040
4253
  await fireEvent.click(editRow('enabled').querySelector('.options-readfirst-clear')!);
4254
+ let confirmationModal: HTMLElement | null = null;
4255
+ await waitFor(
4256
+ () => {
4257
+ confirmationModal = document.querySelector('.reqore-confirmation-modal');
4258
+ expect(confirmationModal).toBeTruthy();
4259
+ },
4260
+ { timeout: 10000 }
4261
+ );
4262
+ await userEvent.click(
4263
+ within(confirmationModal!).getByRole('button', { name: 'Clear value' })
4264
+ );
3041
4265
  await waitFor(() => {
3042
4266
  expect(editRow('enabled').querySelector('.options-readfirst-clear')).not.toBeInTheDocument();
3043
4267
  expect(editRow('enabled').querySelector('.options-readfirst-revert')).toBeInTheDocument();
@@ -3050,6 +4274,14 @@ export const CompactFieldTypesEditing: Story = {
3050
4274
  // to a muted-green "Covers" / "Covered by <X>" once satisfied. Members live in
3051
4275
  // DIFFERENT panels to prove cross-panel linkage.
3052
4276
  export const CompactRequiredGroups: Story = {
4277
+ parameters: {
4278
+ docs: {
4279
+ description: {
4280
+ story:
4281
+ 'Renders a compact form whose options belong to required_groups — the group\'s one-of-required indicator appears in the row rail and clears once any member is filled.',
4282
+ },
4283
+ },
4284
+ },
3053
4285
  args: {
3054
4286
  compact: true,
3055
4287
  minColumnWidth: '300px',
@@ -3090,7 +4322,7 @@ export const CompactRequiredGroups: Story = {
3090
4322
  // contiguous members (byHost/byFile in Connection) cluster into a rail, which
3091
4323
  // carries the grouping in place of a chip; only the lone member (byUrl in
3092
4324
  // General) keeps a "One of" chip — so exactly one chip, not three.
3093
- await _testsWaitForTextsCount('Required not set', undefined, 3);
4325
+ await _testsWaitForTextsCount('—', undefined, 3);
3094
4326
  await _testsWaitForText('Draft');
3095
4327
  await _testsWaitForTextsCount('One of', undefined, 1);
3096
4328
 
@@ -3147,13 +4379,14 @@ export const CompactRequiredGroups: Story = {
3147
4379
  await _testsWaitForText('https://example.com');
3148
4380
 
3149
4381
  // One fulfilled member satisfies the group → the badge flips to Ready and the
3150
- // chips PERSIST but flip to their muted resolution: the filled member shows
3151
- // "Covers", the empty siblings "Covered by 'By URL'", and no "One of" remains.
4382
+ // Once satisfied: the filled member keeps a "Covers" chip; the empty siblings
4383
+ // show their "Covered by 'By URL'" note INLINE (not a chip), and no "One of"
4384
+ // remains. So exactly one required-group chip stays (the coverer's).
3152
4385
  await _testsWaitForText('Ready');
3153
4386
  await _testsWaitForTextsCount('Covered by “By URL”', undefined, 2);
3154
4387
  await _testsWaitForText('Covers');
3155
4388
  await _testsWaitForTextToNotExist('One of');
3156
- await expect(document.querySelectorAll('.options-readfirst-required-group')).toHaveLength(3);
4389
+ await expect(document.querySelectorAll('.options-readfirst-required-group')).toHaveLength(1);
3157
4390
  },
3158
4391
  };
3159
4392
 
@@ -3190,7 +4423,15 @@ const _compactTypeIntoCardRichText = async (field: string, value: string) => {
3190
4423
  // (`[[a, b]]`) locks the row; the lock popover renders the "any of:" group;
3191
4424
  // fulfilling EITHER blocker unlocks (and flashes) the dependent row.
3192
4425
  export const CompactOptionDependsOnOptionOrAnotherOption: Story = {
3193
- parameters: { chromatic: { disable: true } },
4426
+ parameters: {
4427
+ docs: {
4428
+ description: {
4429
+ story:
4430
+ 'Renders a compact form with an option that depends on A OR B — the row is locked with a dependency popover listing both alternatives; fulfilling either unlocks the row.',
4431
+ },
4432
+ },
4433
+ chromatic: { disable: true },
4434
+ },
3194
4435
  args: {
3195
4436
  compact: true,
3196
4437
  minColumnWidth: '300px',
@@ -3299,7 +4540,15 @@ export const CompactOptionDependsOnOptionOrAnotherOption: Story = {
3299
4540
  // The dependency targets a required-group MEMBER: fulfilling it unlocks the
3300
4541
  // dependent row AND satisfies the group — both linkage systems on one form.
3301
4542
  export const CompactOptionDependsOnOptionInRequiredGroup: Story = {
3302
- parameters: { chromatic: { disable: true } },
4543
+ parameters: {
4544
+ docs: {
4545
+ description: {
4546
+ story:
4547
+ 'Renders a compact form with an option that depends on a required-group member — the row locks until the required-group option is filled.',
4548
+ },
4549
+ },
4550
+ chromatic: { disable: true },
4551
+ },
3303
4552
  args: {
3304
4553
  compact: true,
3305
4554
  minColumnWidth: '300px',
@@ -3380,7 +4629,15 @@ export const CompactOptionDependsOnOptionInRequiredGroup: Story = {
3380
4629
 
3381
4630
  // An `any`-typed option shows its value and expands to the type-aware editor.
3382
4631
  export const CompactAnyType: Story = {
3383
- parameters: { chromatic: { disable: true } },
4632
+ parameters: {
4633
+ docs: {
4634
+ description: {
4635
+ story:
4636
+ 'Renders a compact form with any-typed options — the read rows summarise the current value and the editor lets the operator pick the concrete type through the More menu.',
4637
+ },
4638
+ },
4639
+ chromatic: { disable: true },
4640
+ },
3384
4641
  args: {
3385
4642
  compact: true,
3386
4643
  minColumnWidth: '300px',
@@ -3401,7 +4658,15 @@ export const CompactAnyType: Story = {
3401
4658
  // A schema-level `readonly` field whose value differs from its default is fixed
3402
4659
  // back to the default (engine `fixOptions`); the read row shows the default.
3403
4660
  export const CompactReadonlyDefaultFix: Story = {
3404
- parameters: { chromatic: { disable: true } },
4661
+ parameters: {
4662
+ docs: {
4663
+ description: {
4664
+ story:
4665
+ 'Renders a compact form with a read-only option whose value differs from default_value — the value auto-corrects to the default at mount without the wrong value ever rendering.',
4666
+ },
4667
+ },
4668
+ chromatic: { disable: true },
4669
+ },
3405
4670
  args: {
3406
4671
  compact: true,
3407
4672
  minColumnWidth: '300px',
@@ -3425,7 +4690,15 @@ export const CompactReadonlyDefaultFix: Story = {
3425
4690
 
3426
4691
  // Values for options that aren't in the schema are filtered out, not rendered.
3427
4692
  export const CompactNonExistentFiltered: Story = {
3428
- parameters: { chromatic: { disable: true } },
4693
+ parameters: {
4694
+ docs: {
4695
+ description: {
4696
+ story:
4697
+ 'Renders a compact form with a value carrying an extra field that isn\'t in the schema — the extra field is filtered out and no row is rendered for it.',
4698
+ },
4699
+ },
4700
+ chromatic: { disable: true },
4701
+ },
3429
4702
  args: {
3430
4703
  compact: true,
3431
4704
  minColumnWidth: '300px',
@@ -3445,7 +4718,15 @@ export const CompactNonExistentFiltered: Story = {
3445
4718
 
3446
4719
  // A field with a long `desc` shows a help affordance that opens the help dialog.
3447
4720
  export const CompactHelpDialog: Story = {
3448
- parameters: { chromatic: { disable: true } },
4721
+ parameters: {
4722
+ docs: {
4723
+ description: {
4724
+ story:
4725
+ 'Renders a compact form and clicks a row\'s Help action — the help dialog opens with the option\'s long-form description.',
4726
+ },
4727
+ },
4728
+ chromatic: { disable: true },
4729
+ },
3449
4730
  args: {
3450
4731
  compact: true,
3451
4732
  minColumnWidth: '300px',
@@ -3471,7 +4752,15 @@ export const CompactHelpDialog: Story = {
3471
4752
 
3472
4753
  // Read-first rendering is render-stable — it doesn't emit a storm of onChanges.
3473
4754
  export const CompactDoesNotCauseInfiniteRerenders: Story = {
3474
- parameters: { chromatic: { disable: true } },
4755
+ parameters: {
4756
+ docs: {
4757
+ description: {
4758
+ story:
4759
+ 'Renders a compact form with a refetch-triggering parent and a list-of-hash dependent — mounting and interacting with the form does not trigger runaway re-renders.',
4760
+ },
4761
+ },
4762
+ chromatic: { disable: true },
4763
+ },
3475
4764
  args: {
3476
4765
  compact: true,
3477
4766
  minColumnWidth: '300px',
@@ -3500,7 +4789,15 @@ const loadCompactSchemaAsync = (): Promise<IQorusFormSchema> =>
3500
4789
  // the story earns its keep through the play test (the resolve path), not a
3501
4790
  // snapshot — the loading state has its own story below.
3502
4791
  export const CompactOptionsLoader: Story = {
3503
- parameters: { chromatic: { disable: true } },
4792
+ parameters: {
4793
+ docs: {
4794
+ description: {
4795
+ story:
4796
+ 'Renders a compact form whose options are fetched via a url — the loader skeleton is shown until the schema resolves, then the compact rows mount.',
4797
+ },
4798
+ },
4799
+ chromatic: { disable: true },
4800
+ },
3504
4801
  args: {
3505
4802
  compact: true,
3506
4803
  minColumnWidth: '300px',
@@ -3518,6 +4815,14 @@ export const CompactOptionsLoader: Story = {
3518
4815
 
3519
4816
  // A rejected load surfaces the engine's error state instead of the form.
3520
4817
  export const CompactOptionsLoaderError: Story = {
4818
+ parameters: {
4819
+ docs: {
4820
+ description: {
4821
+ story:
4822
+ 'Renders a compact form whose options fetch fails — the loader resolves into an error message rather than crashing.',
4823
+ },
4824
+ },
4825
+ },
3521
4826
  args: {
3522
4827
  compact: true,
3523
4828
  minColumnWidth: '300px',
@@ -3538,6 +4843,14 @@ export const CompactOptionsLoaderError: Story = {
3538
4843
  // and the snapshot Chromatic captures. The resolve path (load → form →
3539
4844
  // `onOptionsLoaded`) is exercised by `CompactOptionsLoader` above.
3540
4845
  export const OptionsLoader: Story = {
4846
+ parameters: {
4847
+ docs: {
4848
+ description: {
4849
+ story:
4850
+ 'Renders the classic FormEngine whose options are fetched via a url — the loader is shown until the schema resolves.',
4851
+ },
4852
+ },
4853
+ },
3541
4854
  args: {
3542
4855
  minColumnWidth: '300px',
3543
4856
  value: CompactValue,
@@ -3656,6 +4969,14 @@ const infoDisplayArgs = {
3656
4969
  // The flagship "real form" story: the Basic fixture + stress fields with full
3657
4970
  // descriptions — how compact mode looks and feels in actual use.
3658
4971
  export const CompactShowcase: Story = {
4972
+ parameters: {
4973
+ docs: {
4974
+ description: {
4975
+ story:
4976
+ 'Renders the flagship compact-mode showcase — the Basic fixture plus every stress field type across a real-form layout, used as the compact-mode reference screenshot.',
4977
+ },
4978
+ },
4979
+ },
3659
4980
  args: infoDisplayArgs,
3660
4981
  play: async () => {
3661
4982
  await _testsWaitForText('API endpoint');
@@ -3682,63 +5003,55 @@ export const CompactShowcase: Story = {
3682
5003
  ).toBeTruthy();
3683
5004
  await waitFor(() => {
3684
5005
  // The intent stripe rides the value surface's left border, fed by
3685
- // --readfirst-stripe on the field's BLOCK root. This field carries a
3686
- // message, so the block root is the info-row wrapper (not the inner row).
5006
+ // --readfirst-stripe on the field's BLOCK root. Schema messages now render
5007
+ // inside the value cell, so the block root is the row itself.
3687
5008
  const intentRow = document.querySelector(
3688
- '.options-readfirst-info-row[data-field="chromeIntent"]'
5009
+ '.readfirst-row[data-field="chromeIntent"]'
3689
5010
  ) as HTMLElement;
3690
5011
  expect(intentRow?.style?.getPropertyValue('--readfirst-stripe')).toBeTruthy();
3691
5012
  });
3692
5013
  await _testsWaitForText('••••••');
3693
5014
  await _testsWaitForText('This field also carries a warning message.');
3694
- // The required-group pair (authToken/authCertFile, contiguous) clusters into a
3695
- // connection rail a status node per member, not a per-row "One of" chip —
3696
- // alongside the info affordances.
5015
+ // The unmet auth one-of group (authToken/authCertFile) renders the
5016
+ // "One of the below is required" cluster box.
3697
5017
  await waitFor(() =>
3698
- expect(document.querySelectorAll('.options-readfirst-node').length).toBeGreaterThan(0)
5018
+ expect(document.querySelector('.options-readfirst-required-cluster')).toBeTruthy()
3699
5019
  );
3700
5020
 
3701
- // Toggling a panel adds/removes the info-row wrapper, REBUILDING the row's
3702
- // DOM re-query the toggle for every click and assert panel state on the
3703
- // wrapper element.
3704
- const infoToggle = (field: string) =>
3705
- document.querySelector(
3706
- `.readfirst-row[data-field="${field}"] .options-readfirst-info-slot .options-readfirst-info-toggle`
3707
- ) as HTMLElement;
5021
+ // Schema message panels render inside the value cell of the row itself,
5022
+ // directly beneath the value.
3708
5023
  const infoPanel = (field: string) =>
3709
5024
  document.querySelector(
3710
- `.options-readfirst-info-row[data-field="${field}"] .options-readfirst-info-panel`
5025
+ `.readfirst-row[data-field="${field}"] .options-readfirst-info-panel`
3711
5026
  );
3712
5027
 
3713
- // Tier-2-only fields (info messages, default-value notes) stay one line:
3714
- // panel closed, toggle in the fixed slot. Toggling open reveals the
3715
- // default-value note; toggling again hides it.
3716
- await expect(infoPanel('metaDefault')).toBeNull();
3717
- await expect(infoToggle('metaDefault')).toBeTruthy();
3718
- await fireEvent.click(infoToggle('metaDefault'));
5028
+ // Default-value notes and validation/dependency hints now render as a compact
5029
+ // INLINE reason (no ⓘ, no panel) visible without any interaction.
3719
5030
  await _testsWaitForText('Default: thirty — Falls back to 30 seconds when unset.');
3720
- await fireEvent.click(infoToggle('metaDefault'));
3721
- await waitFor(() => expect(infoPanel('metaDefault')).toBeNull());
3722
-
3723
- // Auto-open panels can be dismissed the same way (override sticks).
5031
+ // Dedicated schema messages stay prominent PANELS, also always visible.
3724
5032
  await expect(infoPanel('apiEndpoint')).toBeTruthy();
3725
- await fireEvent.click(infoToggle('apiEndpoint'));
3726
- await waitFor(() => expect(infoPanel('apiEndpoint')).toBeNull());
3727
- await fireEvent.click(infoToggle('apiEndpoint'));
3728
5033
  await _testsWaitForText('v1 endpoints are deprecated — migrate to /v2 before 2026-09.');
3729
5034
 
3730
- // short_desc renders UNDER the field name, gated by the same ⓘ: a message-free
3731
- // field keeps it hidden until toggled, then reveals it under the name.
5035
+ // short_desc renders UNDER the field name when the global descriptions toggle
5036
+ // is engaged (the per-row is gone).
3732
5037
  const labelDesc = (field: string) =>
3733
5038
  document.querySelector(`.readfirst-row[data-field="${field}"] .options-readfirst-label-desc`);
3734
5039
  await expect(labelDesc('chromeIcon')).toBeNull();
3735
- await fireEvent.click(infoToggle('chromeIcon'));
5040
+ await fireEvent.click(document.querySelector('.options-readfirst-descriptions') as HTMLElement);
3736
5041
  await waitFor(() => expect(labelDesc('chromeIcon')).toBeTruthy());
3737
5042
  },
3738
5043
  };
3739
5044
 
3740
5045
  // The same stress form in a 360 px container — stacked rows, panels full-width.
3741
5046
  export const CompactShowcaseMobile: Story = {
5047
+ parameters: {
5048
+ docs: {
5049
+ description: {
5050
+ story:
5051
+ 'Renders the compact showcase at a ~390px mobile viewport — the compact rows collapse into a single-column stack.',
5052
+ },
5053
+ },
5054
+ },
3742
5055
  args: infoDisplayArgs,
3743
5056
  decorators: [
3744
5057
  (StoryComponent: React.ComponentType) => (
@@ -3758,6 +5071,14 @@ export const CompactShowcaseMobile: Story = {
3758
5071
  // credential pair (one provided → green node + Provided badge) and a 4-member
3759
5072
  // notification group (none set → violet, pending).
3760
5073
  export const CompactRequiredGroupRails: Story = {
5074
+ parameters: {
5075
+ docs: {
5076
+ description: {
5077
+ story:
5078
+ 'Renders a compact form with several required_groups — each group\'s rail sits alongside its rows so the operator can see which one-of-required set the row belongs to.',
5079
+ },
5080
+ },
5081
+ },
3761
5082
  args: {
3762
5083
  compact: true,
3763
5084
  minColumnWidth: '320px',
@@ -3817,38 +5138,14 @@ export const CompactRequiredGroupRails: Story = {
3817
5138
  },
3818
5139
  play: async () => {
3819
5140
  await _testsWaitForText('API key');
3820
- // Members are clustered with a status node each; the two groups give 6 nodes.
5141
+ // The unmet `target` group (email/slack/webhook/sms, none set) renders the
5142
+ // "One of the below is required" cluster box.
3821
5143
  await waitFor(() =>
3822
- expect(document.querySelectorAll('.options-readfirst-node').length).toBe(6)
5144
+ expect(document.querySelector('.options-readfirst-required-cluster')).toBeTruthy()
3823
5145
  );
3824
- // The set member's node is filled (satisfies its group); pending ones are
3825
- // hollow (transparent centre painted the form bg).
3826
- await waitFor(() => {
3827
- const apiNode = document.querySelector(
3828
- '.readfirst-row[data-field="apiKey"] .options-readfirst-node'
3829
- ) as HTMLElement;
3830
- const oauthNode = document.querySelector(
3831
- '.readfirst-row[data-field="oauthToken"] .options-readfirst-node'
3832
- ) as HTMLElement;
3833
- // email is in the `target` group, which has NO member set — still unmet.
3834
- const emailNode = document.querySelector(
3835
- '.readfirst-row[data-field="email"] .options-readfirst-node'
3836
- ) as HTMLElement;
3837
- // Fill marks the member carrying the value: apiKey filled (bg = its border
3838
- // colour), oauthToken hollow (bg = the form background).
3839
- expect(getComputedStyle(apiNode).backgroundColor).not.toBe(
3840
- getComputedStyle(oauthNode).backgroundColor
3841
- );
3842
- // Colour follows the GROUP, not the member. The credential group is satisfied
3843
- // by apiKey, so its empty alternative (oauthToken) reads the SAME colour as
3844
- // the filled node — not warning. The still-unmet target group's node (email)
3845
- // keeps the warning colour, so it differs.
3846
- const apiBorder = getComputedStyle(apiNode).borderTopColor;
3847
- const oauthBorder = getComputedStyle(oauthNode).borderTopColor;
3848
- const emailBorder = getComputedStyle(emailNode).borderTopColor;
3849
- expect(oauthBorder).toBe(apiBorder);
3850
- expect(oauthBorder).not.toBe(emailBorder);
3851
- });
5146
+ // The met `credential` group needs no box: apiKey satisfies it, so its empty
5147
+ // alternative oauthToken reads as covered by its sibling.
5148
+ await _testsWaitForText('Covered by “API key”');
3852
5149
  },
3853
5150
  };
3854
5151
 
@@ -3856,7 +5153,15 @@ export const CompactRequiredGroupRails: Story = {
3856
5153
  // keeps required-group clusters contiguous (rails intact) — never interleaving
3857
5154
  // across groups. Regression cover for the compact sort.
3858
5155
  export const CompactFieldSortWithinGroups: Story = {
3859
- parameters: { chromatic: { disable: true } },
5156
+ parameters: {
5157
+ docs: {
5158
+ description: {
5159
+ story:
5160
+ 'Renders a compact form with grouped options that carry sort ordinals — rows within each group render in sort order, not schema-declaration order.',
5161
+ },
5162
+ },
5163
+ chromatic: { disable: true },
5164
+ },
3860
5165
  args: CompactRequiredGroupRails.args,
3861
5166
  play: async () => {
3862
5167
  await _testsWaitForText('API key');
@@ -3867,9 +5172,12 @@ export const CompactFieldSortWithinGroups: Story = {
3867
5172
  )
3868
5173
  ).map((row) => row.getAttribute('data-field'));
3869
5174
 
3870
- // Default = the schema's declared order.
5175
+ // Rows are bucketed into status boxes (Needs attention → Set → Optional);
5176
+ // within a box, schema order holds. The `target` group is unmet (attention),
5177
+ // the `credential` group is met by apiKey (set), so the attention members
5178
+ // come first, then the set members.
3871
5179
  await waitFor(() =>
3872
- expect(order()).toEqual(['apiKey', 'oauthToken', 'email', 'slack', 'webhook', 'sms'])
5180
+ expect(order()).toEqual(['email', 'slack', 'webhook', 'sms', 'apiKey', 'oauthToken'])
3873
5181
  );
3874
5182
 
3875
5183
  // Open the Fields menu, drill into the (collapsed) "Sort by" submenu, then
@@ -3887,13 +5195,279 @@ export const CompactFieldSortWithinGroups: Story = {
3887
5195
  await waitFor(() => expect(menuItem('Name A→Z')).toBeTruthy());
3888
5196
  await fireEvent.click(menuItem('Name A→Z') as HTMLElement);
3889
5197
 
3890
- // Sorted by display name inside each group; the two groups stay separate
3891
- // (Connection: apiKey/oauthToken | Notification: email/slack/sms/webhook).
5198
+ // Sorted by display name inside each group; the status boxes stay separate
5199
+ // (attention: email/slack/sms/webhook | set: apiKey/oauthToken).
3892
5200
  await waitFor(() =>
3893
- expect(order()).toEqual(['apiKey', 'oauthToken', 'email', 'slack', 'sms', 'webhook'])
5201
+ expect(order()).toEqual(['email', 'slack', 'sms', 'webhook', 'apiKey', 'oauthToken'])
3894
5202
  );
3895
- // The required-group clusters survive the re-sort: all 6 nodes + both rails.
3896
- await expect(document.querySelectorAll('.options-readfirst-node')).toHaveLength(6);
5203
+ // The required-group clusters survive the re-sort (both groups keep their
5204
+ // first-member marker, used to anchor the cluster box).
3897
5205
  await expect(document.querySelectorAll('.readfirst-cluster-first')).toHaveLength(2);
3898
5206
  },
3899
5207
  };
5208
+
5209
+ // `autoFocusFirstRequired` drops the user straight into the first field they
5210
+ // must fill. Here `name` is required-but-filled and `description` is
5211
+ // required-but-empty, so the engine expands the `description` row on mount and
5212
+ // its editor takes focus — no click, no DOM scraping.
5213
+ export const CompactAutoFocusFirstRequired: Story = {
5214
+ parameters: {
5215
+ docs: {
5216
+ description: {
5217
+ story:
5218
+ 'Renders a compact form with autoFocus enabled — the first unfilled required row opens for editing automatically on mount.',
5219
+ },
5220
+ },
5221
+ },
5222
+ args: {
5223
+ compact: true,
5224
+ minColumnWidth: '300px',
5225
+ options: CompactSchema,
5226
+ value: CompactValue,
5227
+ groups: CompactGroups,
5228
+ autoFocusFirstRequired: true,
5229
+ },
5230
+ play: async () => {
5231
+ await _testsWaitForText('order-fulfilment');
5232
+
5233
+ // The first empty required field (`description`) auto-expands into its
5234
+ // inline editor without any interaction.
5235
+ await waitFor(
5236
+ () =>
5237
+ expect(
5238
+ document.querySelector(
5239
+ '[data-field="description"] input, [data-field="description"] textarea'
5240
+ )
5241
+ ).toBeTruthy(),
5242
+ { timeout: 10000 }
5243
+ );
5244
+
5245
+ // …and that editor receives focus (CompactRow focuses the expanded row's
5246
+ // control). `name` is required but already filled, so it is skipped.
5247
+ await waitFor(
5248
+ () => {
5249
+ const active = document.activeElement as HTMLElement | null;
5250
+ const field = document.querySelector('[data-field="description"]');
5251
+ expect(!!active && !!field && field.contains(active)).toBe(true);
5252
+ },
5253
+ { timeout: 10000 }
5254
+ );
5255
+
5256
+ // The already-satisfied required field is NOT expanded/focused.
5257
+ const nameField = document.querySelector('[data-field="name"]');
5258
+ expect(nameField?.contains(document.activeElement)).toBeFalsy();
5259
+ },
5260
+ };
5261
+
5262
+ // `initialExpandedOptions` opens rows the CALLER names, for the case where the
5263
+ // expanded row is part of an address rather than a click: a field that renders
5264
+ // its own routable surface can restore the pane from the URL, but not the row
5265
+ // that has to be open for the pane to exist. Here `description` starts expanded
5266
+ // with no interaction, while a row the caller did not name stays collapsed.
5267
+ export const CompactInitialExpandedOptions: Story = {
5268
+ parameters: {
5269
+ docs: {
5270
+ description: {
5271
+ story:
5272
+ 'Renders a compact form with initialExpandedOptions — the caller-named row is already open on mount, and rows it did not name stay collapsed.',
5273
+ },
5274
+ },
5275
+ },
5276
+ args: {
5277
+ compact: true,
5278
+ minColumnWidth: '300px',
5279
+ options: CompactSchema,
5280
+ value: CompactValue,
5281
+ groups: CompactGroups,
5282
+ initialExpandedOptions: ['description'],
5283
+ },
5284
+ play: async () => {
5285
+ await _testsWaitForText('order-fulfilment');
5286
+
5287
+ // The caller-named row is open on mount, with no click.
5288
+ await waitFor(
5289
+ () =>
5290
+ expect(
5291
+ document.querySelector(
5292
+ '[data-field="description"] input, [data-field="description"] textarea'
5293
+ )
5294
+ ).toBeTruthy(),
5295
+ { timeout: 10000 }
5296
+ );
5297
+
5298
+ // A row the caller did not name is NOT expanded.
5299
+ expect(
5300
+ document.querySelector('[data-field="name"] input, [data-field="name"] textarea')
5301
+ ).toBeFalsy();
5302
+ },
5303
+ };
5304
+
5305
+ // An unknown name must not throw or expand anything — a stale link naming a
5306
+ // field this schema no longer has just lands on the collapsed form.
5307
+ export const CompactInitialExpandedOptionsUnknownName: Story = {
5308
+ parameters: {
5309
+ docs: {
5310
+ description: {
5311
+ story:
5312
+ 'Renders a compact form whose initialExpandedOptions names a field that does not exist — the form renders normally with every row collapsed.',
5313
+ },
5314
+ },
5315
+ },
5316
+ args: {
5317
+ compact: true,
5318
+ minColumnWidth: '300px',
5319
+ options: CompactSchema,
5320
+ value: CompactValue,
5321
+ groups: CompactGroups,
5322
+ initialExpandedOptions: ['no-such-field'],
5323
+ },
5324
+ play: async () => {
5325
+ await _testsWaitForText('order-fulfilment');
5326
+ await sleep(300);
5327
+ expect(
5328
+ document.querySelector(
5329
+ '[data-field="description"] input, [data-field="description"] textarea'
5330
+ )
5331
+ ).toBeFalsy();
5332
+ },
5333
+ };
5334
+
5335
+ // A required field can be FILLED yet INVALID — here `endpoint` has a value that
5336
+ // fails its own `validation_regex` (it must be an http(s) URL). It still "needs
5337
+ // attention", so autofocus must land on IT and not skip ahead to the empty
5338
+ // `description` (which the old empty-only selector would have chosen). This is
5339
+ // the regression guard for the "filled-but-invalid" drift.
5340
+ const CompactInvalidFilledSchema: Record<string, TCompactField> = {
5341
+ endpoint: {
5342
+ type: 'string',
5343
+ ui_type: 'string',
5344
+ display_name: 'Endpoint URL',
5345
+ required: true,
5346
+ group: 'info',
5347
+ // Real format constraint: the value must be an http(s) URL. `validation_regex`
5348
+ // is read by the engine's own validation (helpers/validations.ts); it's not on
5349
+ // the base schema type, so cast.
5350
+ validation_regex: '^https?://',
5351
+ } as TCompactField,
5352
+ description: {
5353
+ type: 'string',
5354
+ ui_type: 'string',
5355
+ display_name: 'Description',
5356
+ required: true,
5357
+ group: 'general',
5358
+ },
5359
+ };
5360
+
5361
+ const CompactInvalidFilledValue: IOptions = {
5362
+ endpoint: { type: 'string', value: 'bad-endpoint' }, // filled, but not an http(s) URL → fails validation_regex
5363
+ // description left empty
5364
+ };
5365
+
5366
+ export const CompactAutoFocusTargetsInvalidFilledField: Story = {
5367
+ parameters: {
5368
+ docs: {
5369
+ description: {
5370
+ story:
5371
+ 'Renders a compact form with autoFocus enabled and a required field holding an invalid value — the autofocus targets the invalid filled field rather than the next empty required.',
5372
+ },
5373
+ },
5374
+ },
5375
+ args: {
5376
+ compact: true,
5377
+ minColumnWidth: '300px',
5378
+ options: CompactInvalidFilledSchema,
5379
+ value: CompactInvalidFilledValue,
5380
+ groups: CompactGroups,
5381
+ autoFocusFirstRequired: true,
5382
+ },
5383
+ play: async () => {
5384
+ await _testsWaitForText('Endpoint URL');
5385
+
5386
+ // Sanity: the value genuinely fails the field's own validation (it isn't an
5387
+ // http(s) URL), using the very same validator the engine runs — so this is a
5388
+ // real filled-but-INVALID field, not a synthetically-flagged one.
5389
+ expect(validateField('string', 'bad-endpoint', { validation_regex: '^https?://' })).toBe(false);
5390
+
5391
+ // `endpoint` has a value but is invalid, so it needs attention and must be
5392
+ // the target — even though `description` is the empty required field the old
5393
+ // empty-only logic would have picked.
5394
+ await waitFor(
5395
+ () =>
5396
+ expect(
5397
+ document.querySelector('[data-field="endpoint"] input, [data-field="endpoint"] textarea')
5398
+ ).toBeTruthy(),
5399
+ { timeout: 10000 }
5400
+ );
5401
+
5402
+ await waitFor(
5403
+ () => {
5404
+ const active = document.activeElement as HTMLElement | null;
5405
+ const field = document.querySelector('[data-field="endpoint"]');
5406
+ expect(!!active && !!field && field.contains(active)).toBe(true);
5407
+ },
5408
+ { timeout: 10000 }
5409
+ );
5410
+
5411
+ // The empty `description` is NOT the one expanded/focused.
5412
+ const descField = document.querySelector('[data-field="description"]');
5413
+ expect(descField?.contains(document.activeElement)).toBeFalsy();
5414
+ },
5415
+ };
5416
+
5417
+ // DEMO / manual-test story: the first "needs attention" field is a BOOLEAN,
5418
+ // whose compact editor is a `<div tabindex=0>` (ReqoreCheckbox) — NOT an
5419
+ // input/textarea/contenteditable that CompactRow's focus selector matches. Both
5420
+ // `enabled` and `name` are required and unset, so both need attention; `enabled`
5421
+ // is first. Open this in Storybook to see the UX: autofocus TARGETS the boolean
5422
+ // (its row expands) and does NOT skip ahead to the focusable `name` field — but
5423
+ // no element inside actually receives keyboard focus, so the caret is left
5424
+ // nowhere. (This is the non-text-editor focus gap; kept as a playground rather
5425
+ // than a hard assertion until we decide how CompactRow should focus such rows.)
5426
+ const CompactNonFocusableFirstSchema: Record<string, TCompactField> = {
5427
+ enabled: {
5428
+ type: 'bool',
5429
+ ui_type: 'bool',
5430
+ display_name: 'Enabled',
5431
+ short_desc: 'A boolean — the first field that needs attention',
5432
+ required: true,
5433
+ preselected: true,
5434
+ group: 'info',
5435
+ },
5436
+ name: {
5437
+ type: 'string',
5438
+ ui_type: 'string',
5439
+ display_name: 'Name',
5440
+ short_desc: 'A focusable text field — comes second',
5441
+ required: true,
5442
+ preselected: true,
5443
+ group: 'general',
5444
+ },
5445
+ };
5446
+
5447
+ const CompactNonFocusableFirstValue: IOptions = {}; // both unset → both need attention
5448
+
5449
+ export const CompactAutoFocusNonFocusableFirstField: Story = {
5450
+ parameters: {
5451
+ docs: {
5452
+ description: {
5453
+ story:
5454
+ 'Renders a compact form with autoFocus enabled where the first needs-attention field is a boolean (not focusable) — the autofocus skips it and opens the next focusable field instead.',
5455
+ },
5456
+ },
5457
+ },
5458
+ args: {
5459
+ compact: true,
5460
+ minColumnWidth: '300px',
5461
+ options: CompactNonFocusableFirstSchema,
5462
+ value: CompactNonFocusableFirstValue,
5463
+ groups: CompactGroups,
5464
+ autoFocusFirstRequired: true,
5465
+ },
5466
+ play: async () => {
5467
+ // Smoke: both required-but-unset fields render (both land in "needs
5468
+ // attention"), with the boolean first. Focus behaviour is intentionally left
5469
+ // for manual observation — see the note above.
5470
+ await _testsWaitForText('Enabled');
5471
+ await _testsWaitForText('Name');
5472
+ },
5473
+ };