@panphora/bevel 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (539) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +226 -0
  3. package/bevel-fonts.css +16 -0
  4. package/bevel.css +3075 -0
  5. package/bevel.js +2126 -0
  6. package/dist/react/components/activity-feed.d.ts +89 -0
  7. package/dist/react/components/activity-feed.js +54 -0
  8. package/dist/react/components/alert-dialog.d.ts +14 -0
  9. package/dist/react/components/alert-dialog.js +19 -0
  10. package/dist/react/components/app-shell.d.ts +98 -0
  11. package/dist/react/components/app-shell.js +86 -0
  12. package/dist/react/components/app-toolbar.d.ts +19 -0
  13. package/dist/react/components/app-toolbar.js +22 -0
  14. package/dist/react/components/avatar-group.d.ts +67 -0
  15. package/dist/react/components/avatar-group.js +49 -0
  16. package/dist/react/components/badge-and-pill.d.ts +34 -0
  17. package/dist/react/components/badge-and-pill.js +12 -0
  18. package/dist/react/components/breadcrumbs.d.ts +56 -0
  19. package/dist/react/components/breadcrumbs.js +44 -0
  20. package/dist/react/components/bulk-bar.d.ts +22 -0
  21. package/dist/react/components/bulk-bar.js +59 -0
  22. package/dist/react/components/button.d.ts +15 -0
  23. package/dist/react/components/button.js +20 -0
  24. package/dist/react/components/callout-and-hint.d.ts +17 -0
  25. package/dist/react/components/callout-and-hint.js +8 -0
  26. package/dist/react/components/checkbox.d.ts +5 -0
  27. package/dist/react/components/checkbox.js +13 -0
  28. package/dist/react/components/chip.d.ts +149 -0
  29. package/dist/react/components/chip.js +29 -0
  30. package/dist/react/components/code-block.d.ts +50 -0
  31. package/dist/react/components/code-block.js +77 -0
  32. package/dist/react/components/code-editor.d.ts +122 -0
  33. package/dist/react/components/code-editor.js +695 -0
  34. package/dist/react/components/column-manager.d.ts +28 -0
  35. package/dist/react/components/column-manager.js +85 -0
  36. package/dist/react/components/combobox.d.ts +32 -0
  37. package/dist/react/components/combobox.js +37 -0
  38. package/dist/react/components/data-grid.d.ts +108 -0
  39. package/dist/react/components/data-grid.js +159 -0
  40. package/dist/react/components/data-table.d.ts +2 -0
  41. package/dist/react/components/data-table.js +6 -0
  42. package/dist/react/components/date.d.ts +23 -0
  43. package/dist/react/components/date.js +11 -0
  44. package/dist/react/components/details.d.ts +13 -0
  45. package/dist/react/components/details.js +20 -0
  46. package/dist/react/components/dialog.d.ts +74 -0
  47. package/dist/react/components/dialog.js +31 -0
  48. package/dist/react/components/diff-view.d.ts +73 -0
  49. package/dist/react/components/diff-view.js +68 -0
  50. package/dist/react/components/documents.d.ts +70 -0
  51. package/dist/react/components/documents.js +10 -0
  52. package/dist/react/components/drawer.d.ts +45 -0
  53. package/dist/react/components/drawer.js +35 -0
  54. package/dist/react/components/editable-grid-cells.d.ts +32 -0
  55. package/dist/react/components/editable-grid-cells.js +116 -0
  56. package/dist/react/components/eyebrow-kicker-lede-pull-quote.d.ts +39 -0
  57. package/dist/react/components/eyebrow-kicker-lede-pull-quote.js +12 -0
  58. package/dist/react/components/field.d.ts +32 -0
  59. package/dist/react/components/field.js +27 -0
  60. package/dist/react/components/file-tree.d.ts +75 -0
  61. package/dist/react/components/file-tree.js +247 -0
  62. package/dist/react/components/file.d.ts +43 -0
  63. package/dist/react/components/file.js +73 -0
  64. package/dist/react/components/files-table.d.ts +174 -0
  65. package/dist/react/components/files-table.js +237 -0
  66. package/dist/react/components/filter-builder.d.ts +37 -0
  67. package/dist/react/components/filter-builder.js +109 -0
  68. package/dist/react/components/form-error-summary.d.ts +16 -0
  69. package/dist/react/components/form-error-summary.js +49 -0
  70. package/dist/react/components/icon-button.d.ts +8 -0
  71. package/dist/react/components/icon-button.js +14 -0
  72. package/dist/react/components/icon.d.ts +7 -0
  73. package/dist/react/components/icon.js +15 -0
  74. package/dist/react/components/image.d.ts +72 -0
  75. package/dist/react/components/image.js +24 -0
  76. package/dist/react/components/inline-edit.d.ts +70 -0
  77. package/dist/react/components/inline-edit.js +149 -0
  78. package/dist/react/components/input.d.ts +29 -0
  79. package/dist/react/components/input.js +20 -0
  80. package/dist/react/components/kanban-board.d.ts +140 -0
  81. package/dist/react/components/kanban-board.js +374 -0
  82. package/dist/react/components/layout.d.ts +51 -0
  83. package/dist/react/components/layout.js +28 -0
  84. package/dist/react/components/list.d.ts +28 -0
  85. package/dist/react/components/list.js +6 -0
  86. package/dist/react/components/masthead-and-verdict.d.ts +60 -0
  87. package/dist/react/components/masthead-and-verdict.js +8 -0
  88. package/dist/react/components/menu.d.ts +26 -0
  89. package/dist/react/components/menu.js +31 -0
  90. package/dist/react/components/multi-select-picker.d.ts +49 -0
  91. package/dist/react/components/multi-select-picker.js +20 -0
  92. package/dist/react/components/note.d.ts +33 -0
  93. package/dist/react/components/note.js +8 -0
  94. package/dist/react/components/number-field.d.ts +16 -0
  95. package/dist/react/components/number-field.js +17 -0
  96. package/dist/react/components/pagination.d.ts +45 -0
  97. package/dist/react/components/pagination.js +87 -0
  98. package/dist/react/components/persistent-action-banner.d.ts +71 -0
  99. package/dist/react/components/persistent-action-banner.js +39 -0
  100. package/dist/react/components/popconfirm.d.ts +42 -0
  101. package/dist/react/components/popconfirm.js +59 -0
  102. package/dist/react/components/popover.d.ts +16 -0
  103. package/dist/react/components/popover.js +21 -0
  104. package/dist/react/components/progress-and-gauge.d.ts +44 -0
  105. package/dist/react/components/progress-and-gauge.js +19 -0
  106. package/dist/react/components/radio.d.ts +7 -0
  107. package/dist/react/components/radio.js +13 -0
  108. package/dist/react/components/rich-text-toolbar.d.ts +26 -0
  109. package/dist/react/components/rich-text-toolbar.js +64 -0
  110. package/dist/react/components/rows-and-card.d.ts +88 -0
  111. package/dist/react/components/rows-and-card.js +10 -0
  112. package/dist/react/components/save-status-indicator.d.ts +11 -0
  113. package/dist/react/components/save-status-indicator.js +33 -0
  114. package/dist/react/components/segmented-control.d.ts +21 -0
  115. package/dist/react/components/segmented-control.js +15 -0
  116. package/dist/react/components/select.d.ts +29 -0
  117. package/dist/react/components/select.js +29 -0
  118. package/dist/react/components/selectable-cards.d.ts +30 -0
  119. package/dist/react/components/selectable-cards.js +29 -0
  120. package/dist/react/components/settings-rows.d.ts +51 -0
  121. package/dist/react/components/settings-rows.js +8 -0
  122. package/dist/react/components/sidebar-nav.d.ts +77 -0
  123. package/dist/react/components/sidebar-nav.js +41 -0
  124. package/dist/react/components/skeleton-and-empty.d.ts +51 -0
  125. package/dist/react/components/skeleton-and-empty.js +14 -0
  126. package/dist/react/components/slider.d.ts +19 -0
  127. package/dist/react/components/slider.js +17 -0
  128. package/dist/react/components/small-charts.d.ts +60 -0
  129. package/dist/react/components/small-charts.js +76 -0
  130. package/dist/react/components/sortable.d.ts +30 -0
  131. package/dist/react/components/sortable.js +85 -0
  132. package/dist/react/components/split-button.d.ts +38 -0
  133. package/dist/react/components/split-button.js +43 -0
  134. package/dist/react/components/split-panes.d.ts +35 -0
  135. package/dist/react/components/split-panes.js +102 -0
  136. package/dist/react/components/stat-tiles.d.ts +54 -0
  137. package/dist/react/components/stat-tiles.js +8 -0
  138. package/dist/react/components/steps.d.ts +11 -0
  139. package/dist/react/components/steps.js +13 -0
  140. package/dist/react/components/switch.d.ts +6 -0
  141. package/dist/react/components/switch.js +13 -0
  142. package/dist/react/components/table.d.ts +61 -0
  143. package/dist/react/components/table.js +6 -0
  144. package/dist/react/components/tabs.d.ts +7 -0
  145. package/dist/react/components/tabs.js +13 -0
  146. package/dist/react/components/tags.d.ts +33 -0
  147. package/dist/react/components/tags.js +83 -0
  148. package/dist/react/components/textarea.d.ts +10 -0
  149. package/dist/react/components/textarea.js +6 -0
  150. package/dist/react/components/toast.d.ts +35 -0
  151. package/dist/react/components/toast.js +90 -0
  152. package/dist/react/components/tooltip-and-key.d.ts +20 -0
  153. package/dist/react/components/tooltip-and-key.js +20 -0
  154. package/dist/react/components/tree-view.d.ts +34 -0
  155. package/dist/react/components/tree-view.js +118 -0
  156. package/dist/react/components/upload-dropzone.d.ts +92 -0
  157. package/dist/react/components/upload-dropzone.js +116 -0
  158. package/dist/react/components/wizard-shell.d.ts +78 -0
  159. package/dist/react/components/wizard-shell.js +176 -0
  160. package/dist/react/fonts.css.d.ts +1 -0
  161. package/dist/react/index.d.ts +76 -0
  162. package/dist/react/index.js +77 -0
  163. package/dist/react/integrations/quickcrop.d.ts +58 -0
  164. package/dist/react/integrations/quickcrop.js +137 -0
  165. package/dist/react/integrations/richclay.d.ts +43 -0
  166. package/dist/react/integrations/richclay.js +129 -0
  167. package/dist/react/internal/classes.d.ts +3 -0
  168. package/dist/react/internal/classes.js +10 -0
  169. package/dist/react/internal/close-mark.d.ts +1 -0
  170. package/dist/react/internal/close-mark.js +10 -0
  171. package/dist/react/internal/controllable-state.d.ts +9 -0
  172. package/dist/react/internal/controllable-state.js +22 -0
  173. package/dist/react/internal/drop-effect.d.ts +28 -0
  174. package/dist/react/internal/drop-effect.js +8 -0
  175. package/dist/react/internal/form-reset.d.ts +2 -0
  176. package/dist/react/internal/form-reset.js +22 -0
  177. package/dist/react/internal/format.d.ts +1 -0
  178. package/dist/react/internal/format.js +14 -0
  179. package/dist/react/internal/link.d.ts +2 -0
  180. package/dist/react/internal/link.js +8 -0
  181. package/dist/react/internal/part.d.ts +20 -0
  182. package/dist/react/internal/part.js +47 -0
  183. package/dist/react/internal/pointer-session.d.ts +8 -0
  184. package/dist/react/internal/pointer-session.js +60 -0
  185. package/dist/react/internal/refs.d.ts +2 -0
  186. package/dist/react/internal/refs.js +22 -0
  187. package/dist/react/internal/rich-text.d.ts +28 -0
  188. package/dist/react/internal/rich-text.js +4 -0
  189. package/dist/react/internal/table-context.d.ts +39 -0
  190. package/dist/react/internal/table-context.js +12 -0
  191. package/dist/react/internal/use-object-url.d.ts +1 -0
  192. package/dist/react/internal/use-object-url.js +15 -0
  193. package/dist/react/layout.css +274 -0
  194. package/dist/react/layout.css.d.ts +1 -0
  195. package/dist/react/models/filter.d.ts +31 -0
  196. package/dist/react/models/filter.js +79 -0
  197. package/dist/react/models/kanban.d.ts +51 -0
  198. package/dist/react/models/kanban.js +35 -0
  199. package/dist/react/models/reorder.d.ts +9 -0
  200. package/dist/react/models/reorder.js +23 -0
  201. package/dist/react/models/table.d.ts +85 -0
  202. package/dist/react/models/table.js +144 -0
  203. package/dist/react/models/tree.d.ts +16 -0
  204. package/dist/react/models/tree.js +26 -0
  205. package/dist/react/provider.d.ts +18 -0
  206. package/dist/react/provider.js +31 -0
  207. package/dist/react/recipes/context-hypercms.d.ts +17 -0
  208. package/dist/react/recipes/context-hypercms.js +71 -0
  209. package/dist/react/recipes/context-quickcrop.d.ts +8 -0
  210. package/dist/react/recipes/context-quickcrop.js +49 -0
  211. package/dist/react/theme.css +8004 -0
  212. package/dist/react/theme.css.d.ts +1 -0
  213. package/examples/README.md +34 -0
  214. package/examples/components/activity-feed.html +28 -0
  215. package/examples/components/app-shell.html +12 -0
  216. package/examples/components/app-toolbar.html +38 -0
  217. package/examples/components/avatar-group.html +27 -0
  218. package/examples/components/breadcrumbs.html +26 -0
  219. package/examples/components/bulk-bar.html +22 -0
  220. package/examples/components/code-block.html +12 -0
  221. package/examples/components/code-editor.html +25 -0
  222. package/examples/components/column-manager.html +9 -0
  223. package/examples/components/combobox.html +13 -0
  224. package/examples/components/context-hypercms.css +41 -0
  225. package/examples/components/context-hypercms.html +40 -0
  226. package/examples/components/context-hypercms.js +62 -0
  227. package/examples/components/context-quickcrop.html +137 -0
  228. package/examples/components/diff-view.html +9 -0
  229. package/examples/components/drawer.html +11 -0
  230. package/examples/components/editable-grid-cells.html +9 -0
  231. package/examples/components/file-browser.html +53 -0
  232. package/examples/components/file-tree.html +34 -0
  233. package/examples/components/files-table.html +80 -0
  234. package/examples/components/filter-builder.html +11 -0
  235. package/examples/components/form-error-summary.html +11 -0
  236. package/examples/components/index.html +51 -0
  237. package/examples/components/inline-edit.html +42 -0
  238. package/examples/components/kanban-board.html +42 -0
  239. package/examples/components/manifest.json +3279 -0
  240. package/examples/components/modal.html +11 -0
  241. package/examples/components/multi-select-picker.html +13 -0
  242. package/examples/components/pagination.html +32 -0
  243. package/examples/components/persistent-action-banner.html +23 -0
  244. package/examples/components/popconfirm.html +13 -0
  245. package/examples/components/popover-menu.html +13 -0
  246. package/examples/components/reference/baseline/DECISIONS.md +85 -0
  247. package/examples/components/reference/baseline/README.md +58 -0
  248. package/examples/components/reference/baseline/bevel-fonts.css +16 -0
  249. package/examples/components/reference/baseline/bevel.css +1339 -0
  250. package/examples/components/reference/baseline/bevel.js +496 -0
  251. package/examples/components/reference/baseline/fonts/LICENSE-ibm-plex-mono.txt +93 -0
  252. package/examples/components/reference/baseline/fonts/LICENSE-ibm-plex-sans.txt +93 -0
  253. package/examples/components/reference/baseline/fonts/LICENSE-newsreader.txt +93 -0
  254. package/examples/components/reference/baseline/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  255. package/examples/components/reference/baseline/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  256. package/examples/components/reference/baseline/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  257. package/examples/components/reference/baseline/fonts/ibm-plex-sans-latin-400-italic.woff2 +0 -0
  258. package/examples/components/reference/baseline/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
  259. package/examples/components/reference/baseline/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
  260. package/examples/components/reference/baseline/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
  261. package/examples/components/reference/baseline/fonts/newsreader-latin-opsz-italic.woff2 +0 -0
  262. package/examples/components/reference/baseline/fonts/newsreader-latin-opsz-normal.woff2 +0 -0
  263. package/examples/components/reference/fable/IMPROVEMENTS.md +106 -0
  264. package/examples/components/reference/fable/PARENT-REPAIRS.md +17 -0
  265. package/examples/components/reference/fable/__pycache__/build_repairs.cpython-311.pyc +0 -0
  266. package/examples/components/reference/fable/__pycache__/kanban.cpython-311.pyc +0 -0
  267. package/examples/components/reference/fable/as-authored/IMPROVEMENTS-completed.md +106 -0
  268. package/examples/components/reference/fable/as-authored/base-astra.css +17 -0
  269. package/examples/components/reference/fable/as-authored/base-parent.css +416 -0
  270. package/examples/components/reference/fable/as-authored/build.py +858 -0
  271. package/examples/components/reference/fable/as-authored/fable.css +823 -0
  272. package/examples/components/reference/fable/as-authored/kanban-completed.css +235 -0
  273. package/examples/components/reference/fable/as-authored/kanban-completed.html +53 -0
  274. package/examples/components/reference/fable/as-authored/kanban-completed.py +212 -0
  275. package/examples/components/reference/fable/as-authored/kanban.py +207 -0
  276. package/examples/components/reference/fable/base-astra.css +17 -0
  277. package/examples/components/reference/fable/base-parent.css +416 -0
  278. package/examples/components/reference/fable/build.py +858 -0
  279. package/examples/components/reference/fable/build_repairs.py +25 -0
  280. package/examples/components/reference/fable/components/activity-feed.html +3 -0
  281. package/examples/components/reference/fable/components/app-toolbar.html +3 -0
  282. package/examples/components/reference/fable/components/avatar-group.html +4 -0
  283. package/examples/components/reference/fable/components/breadcrumbs.html +3 -0
  284. package/examples/components/reference/fable/components/code-block.html +9 -0
  285. package/examples/components/reference/fable/components/column-manager.html +3 -0
  286. package/examples/components/reference/fable/components/combobox.html +6 -0
  287. package/examples/components/reference/fable/components/context-hypercms.html +3 -0
  288. package/examples/components/reference/fable/components/context-quickcrop.html +4 -0
  289. package/examples/components/reference/fable/components/diff-view.html +13 -0
  290. package/examples/components/reference/fable/components/drawer.html +3 -0
  291. package/examples/components/reference/fable/components/editable-grid-cells.html +3 -0
  292. package/examples/components/reference/fable/components/filter-builder.html +3 -0
  293. package/examples/components/reference/fable/components/form-error-summary.html +4 -0
  294. package/examples/components/reference/fable/components/inline-edit.html +3 -0
  295. package/examples/components/reference/fable/components/kanban-board.html +53 -0
  296. package/examples/components/reference/fable/components/modal.html +3 -0
  297. package/examples/components/reference/fable/components/multi-select-picker.html +5 -0
  298. package/examples/components/reference/fable/components/pagination.html +3 -0
  299. package/examples/components/reference/fable/components/persistent-action-banner.html +4 -0
  300. package/examples/components/reference/fable/components/popconfirm.html +5 -0
  301. package/examples/components/reference/fable/components/popover-menu.html +3 -0
  302. package/examples/components/reference/fable/components/rich-text-toolbar.html +3 -0
  303. package/examples/components/reference/fable/components/save-status-indicator.html +2 -0
  304. package/examples/components/reference/fable/components/selectable-cards.html +3 -0
  305. package/examples/components/reference/fable/components/settings-rows.html +3 -0
  306. package/examples/components/reference/fable/components/sidebar-nav.html +4 -0
  307. package/examples/components/reference/fable/components/small-charts.html +5 -0
  308. package/examples/components/reference/fable/components/split-button.html +4 -0
  309. package/examples/components/reference/fable/components/split-panes.html +8 -0
  310. package/examples/components/reference/fable/components/stat-tiles.html +3 -0
  311. package/examples/components/reference/fable/components/toast-stack.html +5 -0
  312. package/examples/components/reference/fable/components/tree-view.html +4 -0
  313. package/examples/components/reference/fable/components/upload-dropzone.html +6 -0
  314. package/examples/components/reference/fable/components/wizard-shell.html +3 -0
  315. package/examples/components/reference/fable/coverage.json +414 -0
  316. package/examples/components/reference/fable/fable.css +823 -0
  317. package/examples/components/reference/fable/index.html +5 -0
  318. package/examples/components/reference/fable/kanban.css +237 -0
  319. package/examples/components/reference/fable/kanban.py +212 -0
  320. package/examples/components/reference/fable/parent-repairs.css +9 -0
  321. package/examples/components/reference/fixtures/data.json +102 -0
  322. package/examples/components/reference/fixtures/host.js +47 -0
  323. package/examples/components/reference/fixtures/static-host.js +39 -0
  324. package/examples/components/reference/fixtures/workspace.jpg +0 -0
  325. package/examples/components/reference/parent/CHANGES.md +179 -0
  326. package/examples/components/reference/parent/__pycache__/kanban.cpython-311.pyc +0 -0
  327. package/examples/components/reference/parent/base-astra.css +17 -0
  328. package/examples/components/reference/parent/base-parent.css +416 -0
  329. package/examples/components/reference/parent/build.py +148 -0
  330. package/examples/components/reference/parent/components/activity-feed.html +2 -0
  331. package/examples/components/reference/parent/components/app-toolbar.html +2 -0
  332. package/examples/components/reference/parent/components/avatar-group.html +4 -0
  333. package/examples/components/reference/parent/components/breadcrumbs.html +2 -0
  334. package/examples/components/reference/parent/components/code-block.html +8 -0
  335. package/examples/components/reference/parent/components/column-manager.html +2 -0
  336. package/examples/components/reference/parent/components/combobox.html +6 -0
  337. package/examples/components/reference/parent/components/context-hypercms.html +2 -0
  338. package/examples/components/reference/parent/components/context-quickcrop.html +4 -0
  339. package/examples/components/reference/parent/components/diff-view.html +4 -0
  340. package/examples/components/reference/parent/components/drawer.html +2 -0
  341. package/examples/components/reference/parent/components/editable-grid-cells.html +2 -0
  342. package/examples/components/reference/parent/components/filter-builder.html +2 -0
  343. package/examples/components/reference/parent/components/form-error-summary.html +2 -0
  344. package/examples/components/reference/parent/components/inline-edit.html +2 -0
  345. package/examples/components/reference/parent/components/kanban-board.html +84 -0
  346. package/examples/components/reference/parent/components/modal.html +2 -0
  347. package/examples/components/reference/parent/components/multi-select-picker.html +5 -0
  348. package/examples/components/reference/parent/components/pagination.html +2 -0
  349. package/examples/components/reference/parent/components/persistent-action-banner.html +4 -0
  350. package/examples/components/reference/parent/components/popconfirm.html +5 -0
  351. package/examples/components/reference/parent/components/popover-menu.html +2 -0
  352. package/examples/components/reference/parent/components/rich-text-toolbar.html +2 -0
  353. package/examples/components/reference/parent/components/save-status-indicator.html +2 -0
  354. package/examples/components/reference/parent/components/selectable-cards.html +2 -0
  355. package/examples/components/reference/parent/components/settings-rows.html +2 -0
  356. package/examples/components/reference/parent/components/sidebar-nav.html +4 -0
  357. package/examples/components/reference/parent/components/small-charts.html +2 -0
  358. package/examples/components/reference/parent/components/split-button.html +5 -0
  359. package/examples/components/reference/parent/components/split-panes.html +8 -0
  360. package/examples/components/reference/parent/components/stat-tiles.html +2 -0
  361. package/examples/components/reference/parent/components/toast-stack.html +5 -0
  362. package/examples/components/reference/parent/components/tree-view.html +4 -0
  363. package/examples/components/reference/parent/components/upload-dropzone.html +6 -0
  364. package/examples/components/reference/parent/components/wizard-shell.html +2 -0
  365. package/examples/components/reference/parent/coverage.json +414 -0
  366. package/examples/components/reference/parent/index.html +1 -0
  367. package/examples/components/reference/parent/kanban.css +114 -0
  368. package/examples/components/reference/parent/kanban.py +55 -0
  369. package/examples/components/reference/parent/refinement.css +53 -0
  370. package/examples/components/reference/selected/build.py +70 -0
  371. package/examples/components/reference/selected/components/activity-feed.html +3 -0
  372. package/examples/components/reference/selected/components/app-toolbar.html +3 -0
  373. package/examples/components/reference/selected/components/avatar-group.html +4 -0
  374. package/examples/components/reference/selected/components/breadcrumbs.html +3 -0
  375. package/examples/components/reference/selected/components/code-block.html +9 -0
  376. package/examples/components/reference/selected/components/column-manager.html +3 -0
  377. package/examples/components/reference/selected/components/combobox.html +6 -0
  378. package/examples/components/reference/selected/components/context-hypercms.html +3 -0
  379. package/examples/components/reference/selected/components/context-quickcrop.html +4 -0
  380. package/examples/components/reference/selected/components/diff-view.html +13 -0
  381. package/examples/components/reference/selected/components/drawer.html +3 -0
  382. package/examples/components/reference/selected/components/editable-grid-cells.html +3 -0
  383. package/examples/components/reference/selected/components/filter-builder.html +3 -0
  384. package/examples/components/reference/selected/components/form-error-summary.html +4 -0
  385. package/examples/components/reference/selected/components/inline-edit.html +3 -0
  386. package/examples/components/reference/selected/components/kanban-board.html +53 -0
  387. package/examples/components/reference/selected/components/modal.html +2 -0
  388. package/examples/components/reference/selected/components/multi-select-picker.html +5 -0
  389. package/examples/components/reference/selected/components/pagination.html +3 -0
  390. package/examples/components/reference/selected/components/persistent-action-banner.html +4 -0
  391. package/examples/components/reference/selected/components/popconfirm.html +5 -0
  392. package/examples/components/reference/selected/components/popover-menu.html +3 -0
  393. package/examples/components/reference/selected/components/rich-text-toolbar.html +3 -0
  394. package/examples/components/reference/selected/components/save-status-indicator.html +2 -0
  395. package/examples/components/reference/selected/components/selectable-cards.html +3 -0
  396. package/examples/components/reference/selected/components/settings-rows.html +3 -0
  397. package/examples/components/reference/selected/components/sidebar-nav.html +4 -0
  398. package/examples/components/reference/selected/components/small-charts.html +5 -0
  399. package/examples/components/reference/selected/components/split-button.html +4 -0
  400. package/examples/components/reference/selected/components/split-panes.html +8 -0
  401. package/examples/components/reference/selected/components/stat-tiles.html +3 -0
  402. package/examples/components/reference/selected/components/toast-stack.html +5 -0
  403. package/examples/components/reference/selected/components/tree-view.html +4 -0
  404. package/examples/components/reference/selected/components/upload-dropzone.html +6 -0
  405. package/examples/components/reference/selected/components/wizard-shell.html +3 -0
  406. package/examples/components/reference/selected/corrections.css +11 -0
  407. package/examples/components/reference/selected/coverage.json +413 -0
  408. package/examples/components/reference/selected/gallery-build.mjs +49 -0
  409. package/examples/components/reference/selected/gallery-spec.json +12 -0
  410. package/examples/components/reference/selected/index.html +1 -0
  411. package/examples/components/reference/selected/kanban-comparison.json +6 -0
  412. package/examples/components/reference/selected/manifest.json +476 -0
  413. package/examples/components/reference/sha256.json +142 -0
  414. package/examples/components/reference/sources/diagonal-close.svg +1 -0
  415. package/examples/components/reference/sources/hyperclay/public-assets/css/hyperclay-app-source.css +614 -0
  416. package/examples/components/reference/sources/hyperclay/public-assets/js/app.js +94 -0
  417. package/examples/components/reference/sources/hyperclayjs/src/ui/theModal.js +897 -0
  418. package/examples/components/reference/sources/hypercms/scripts/build-theme.js +203 -0
  419. package/examples/components/reference/sources/hypercms/src/anchor.js +24 -0
  420. package/examples/components/reference/sources/hypercms/src/apply-loop.js +167 -0
  421. package/examples/components/reference/sources/hypercms/src/diagnostics.js +44 -0
  422. package/examples/components/reference/sources/hypercms/src/enhance.js +173 -0
  423. package/examples/components/reference/sources/hypercms/src/events.js +955 -0
  424. package/examples/components/reference/sources/hypercms/src/form-builder.js +518 -0
  425. package/examples/components/reference/sources/hypercms/src/form-rules.js +208 -0
  426. package/examples/components/reference/sources/hypercms/src/hooks.js +100 -0
  427. package/examples/components/reference/sources/hypercms/src/hypercms-bundle.js +10 -0
  428. package/examples/components/reference/sources/hypercms/src/hypercms.js +347 -0
  429. package/examples/components/reference/sources/hypercms/src/morph.js +10 -0
  430. package/examples/components/reference/sources/hypercms/src/path.js +61 -0
  431. package/examples/components/reference/sources/hypercms/src/place.js +131 -0
  432. package/examples/components/reference/sources/hypercms/src/platform.js +131 -0
  433. package/examples/components/reference/sources/hypercms/src/refresh.js +87 -0
  434. package/examples/components/reference/sources/hypercms/src/richclay-bridge.js +140 -0
  435. package/examples/components/reference/sources/hypercms/src/row-identity.js +111 -0
  436. package/examples/components/reference/sources/hypercms/src/scaffold.js +10 -0
  437. package/examples/components/reference/sources/hypercms/src/session.js +303 -0
  438. package/examples/components/reference/sources/hypercms/src/shell.js +248 -0
  439. package/examples/components/reference/sources/hypercms/src/styles.css +298 -0
  440. package/examples/components/reference/sources/hypercms/src/targets.js +144 -0
  441. package/examples/components/reference/sources/hypercms/src/templates.js +656 -0
  442. package/examples/components/reference/sources/hypercms/src/theme/pixel-quiet.overrides.css +761 -0
  443. package/examples/components/reference/sources/hypercms/src/theme.generated.css +1772 -0
  444. package/examples/components/reference/sources/hypercms/src/toggle.js +666 -0
  445. package/examples/components/reference/sources/hypercms/src/unresolved.js +211 -0
  446. package/examples/components/reference/sources/hypercms/src/vendor/mirk.vendor.js +212 -0
  447. package/examples/components/reference/sources/hypercms/src/views/inline-layer.js +421 -0
  448. package/examples/components/reference/sources/hypercms/src/views/inline.js +1072 -0
  449. package/examples/components/reference/sources/hypercms/src/views/sidebar.js +97 -0
  450. package/examples/components/reference/sources/modal-reference.html +301 -0
  451. package/examples/components/reference/sources/quickcrop/quickcrop.css +124 -0
  452. package/examples/components/reference/sources/quickcrop/quickcrop.js +400 -0
  453. package/examples/components/reference/sources/richclay/richclay.css +416 -0
  454. package/examples/components/reference/sources/richclay/richclay.js +2962 -0
  455. package/examples/components/reference/sources/richclay/src/a11y.js +67 -0
  456. package/examples/components/reference/sources/richclay/src/browser-global.js +7 -0
  457. package/examples/components/reference/sources/richclay/src/buttons.js +254 -0
  458. package/examples/components/reference/sources/richclay/src/hyperclay.js +470 -0
  459. package/examples/components/reference/sources/richclay/src/normalize.js +380 -0
  460. package/examples/components/reference/sources/richclay/src/richclay.js +1356 -0
  461. package/examples/components/reference/sources/richclay/src/sanitize.js +189 -0
  462. package/examples/components/reference/sources/richclay/src/styles.js +23 -0
  463. package/examples/components/reference/sources/richclay/src/toolbar-float.js +175 -0
  464. package/examples/components/reference/sources/richclay/src/toolbar.js +394 -0
  465. package/examples/components/reference/sources/richclay/src/vendor-bundle.js +15 -0
  466. package/examples/components/reference/sources/richclay/vendor/DOMPURIFY-LICENSE +202 -0
  467. package/examples/components/reference/sources/richclay/vendor/DOMPURIFY-LICENSE-MPL +373 -0
  468. package/examples/components/reference/sources/richclay/vendor/SQUIRE-LICENSE +21 -0
  469. package/examples/components/reference/sources/richclay/vendor/purify.es.mjs +2249 -0
  470. package/examples/components/reference/sources/richclay/vendor/purify.min.js +3 -0
  471. package/examples/components/reference/sources/richclay/vendor/squire.js +22 -0
  472. package/examples/components/reference/sources/richclay/vendor/squire.mjs +22 -0
  473. package/examples/components/reference/sources/richclay/vendor/squire.provenance.json +19 -0
  474. package/examples/components/rich-text-toolbar.html +43 -0
  475. package/examples/components/save-status-indicator.html +11 -0
  476. package/examples/components/selectable-cards.html +9 -0
  477. package/examples/components/settings-rows.html +10 -0
  478. package/examples/components/sidebar-nav.html +9 -0
  479. package/examples/components/small-charts.html +21 -0
  480. package/examples/components/split-button.html +13 -0
  481. package/examples/components/split-panes-editor.html +6 -0
  482. package/examples/components/split-panes.html +9 -0
  483. package/examples/components/stat-tiles.html +23 -0
  484. package/examples/components/toast-stack.html +21 -0
  485. package/examples/components/tree-view.html +9 -0
  486. package/examples/components/upload-dropzone.html +88 -0
  487. package/examples/components/wizard-shell.html +50 -0
  488. package/examples/datastar/index.html +38 -0
  489. package/examples/datastar/server.mjs +31 -0
  490. package/examples/fixtures/field-guide.pdf +14 -0
  491. package/examples/fixtures/workspace.jpg +0 -0
  492. package/examples/setup-peers.mjs +35 -0
  493. package/fonts/LICENSE-ibm-plex-mono.txt +93 -0
  494. package/fonts/LICENSE-ibm-plex-sans.txt +93 -0
  495. package/fonts/LICENSE-newsreader.txt +93 -0
  496. package/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  497. package/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  498. package/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  499. package/fonts/ibm-plex-sans-latin-400-italic.woff2 +0 -0
  500. package/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
  501. package/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
  502. package/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
  503. package/fonts/newsreader-latin-opsz-italic.woff2 +0 -0
  504. package/fonts/newsreader-latin-opsz-normal.woff2 +0 -0
  505. package/icons/LICENSE +21 -0
  506. package/icons/house.json +71 -0
  507. package/icons/icons.d.ts +17 -0
  508. package/icons/icons.js +648 -0
  509. package/icons/icons.svg +126 -0
  510. package/icons/marks/build-tray.source.mjs +45 -0
  511. package/icons/marks/hyperclay-local.svg +11 -0
  512. package/icons/phosphor.json +53 -0
  513. package/icons/round.json +8 -0
  514. package/integrations/datastar.js +202 -0
  515. package/integrations/quickcrop.css +26 -0
  516. package/integrations/quickcrop.js +56 -0
  517. package/integrations/richclay.css +100 -0
  518. package/integrations/richclay.js +131 -0
  519. package/package.json +630 -0
  520. package/react/README.md +244 -0
  521. package/src/README.md +83 -0
  522. package/vendor/datastar/datastar-1.0.3.js +10 -0
  523. package/vendor/datastar/provenance.json +6 -0
  524. package/vendor/floating-ui/LICENSE-core +20 -0
  525. package/vendor/floating-ui/LICENSE-dom +20 -0
  526. package/vendor/floating-ui/floating-ui.core.umd.min.js +1 -0
  527. package/vendor/floating-ui/floating-ui.dom.umd.min.js +1 -0
  528. package/vendor/floating-ui/provenance.json +17 -0
  529. package/vendor/richclay/LICENSE +16 -0
  530. package/vendor/richclay/THIRD-PARTY-NOTICES.md +45 -0
  531. package/vendor/richclay/provenance.json +17 -0
  532. package/vendor/richclay/richclay.css +404 -0
  533. package/vendor/richclay/richclay.js +2940 -0
  534. package/vendor/richclay/vendor/DOMPURIFY-LICENSE +202 -0
  535. package/vendor/richclay/vendor/DOMPURIFY-LICENSE-MPL +373 -0
  536. package/vendor/richclay/vendor/SQUIRE-LICENSE +21 -0
  537. package/vendor/richclay/vendor/purify.min.js +3 -0
  538. package/vendor/richclay/vendor/squire.js +22 -0
  539. package/vendor/richclay/vendor/squire.provenance.json +30 -0
@@ -0,0 +1,955 @@
1
+ import { engine } from 'hyper-html-api'
2
+ import { applyWithRollback } from './apply-loop.js'
3
+ import { fromString as pathFromString, getRuleAtPath } from './path.js'
4
+ import { buildItem, fileNameFromUrl, radioGroupName } from './form-builder.js'
5
+ import { scaffold } from './scaffold.js'
6
+ import { autosizeTextarea, enhanceFields } from './enhance.js'
7
+ import { platform } from './platform.js'
8
+
9
+ const BOUND = new WeakSet()
10
+
11
+ // Wraps a structural apply in the pause-before / commit-on-success pattern.
12
+ // Pauses the undo recorder, runs fn, then either commits the captured
13
+ // records (if the apply succeeded) or discards them (if it failed). Either
14
+ // way, the undo stack only ever contains successful applies — never an
15
+ // apply+rollback no-op pair.
16
+ //
17
+ // No-op pass-through when window.hyperclay.undo isn't loaded.
18
+ export function commitWithUndo(label, fn) {
19
+ const u = platform('undo')
20
+ if (!u) return fn()
21
+ u.pause()
22
+ try {
23
+ const result = fn()
24
+ if (result && result.ok) {
25
+ u.commitCaptured(label)
26
+ } else {
27
+ u.discardCaptured()
28
+ }
29
+ return result
30
+ } finally {
31
+ u.resume()
32
+ }
33
+ }
34
+
35
+ // Runs fn with the undo recorder paused and its captured records discarded, so
36
+ // editor-chrome-only DOM mutations (e.g. the body class toggles the shell adds
37
+ // on mount/unmount) never enter the undo stack. No-op pass-through when undo
38
+ // isn't loaded.
39
+ export function suppressUndo(fn) {
40
+ const u = platform('undo')
41
+ if (!u) return fn()
42
+ u.pause()
43
+ try {
44
+ return fn()
45
+ } finally {
46
+ u.discardCaptured()
47
+ u.resume()
48
+ }
49
+ }
50
+
51
+ export function bindEvents(ctx) {
52
+ const { formRoot } = ctx
53
+ if (!formRoot || BOUND.has(formRoot)) return
54
+ BOUND.add(formRoot)
55
+
56
+ const onInput = (e) => {
57
+ const target = e.target
58
+ if (!target || !target.closest) return
59
+ if (!target.closest('[data-hcms-form-root]')) return
60
+ if (!target.matches('input, textarea, select, [contenteditable][data-hcms-field]')) return
61
+ if (target.tagName === 'TEXTAREA') autosizeTextarea(target)
62
+ // The upload picker fires `input` too, but its .value is the OS fake path
63
+ // (C:\fakepath\…); it's handled on `change` via onUploadChange. The wrapper
64
+ // carries data-hcms-field, so without this guard the closest() below would
65
+ // route the fake path into onScalarChange.
66
+ if (target.matches('input[type="file"]')) return
67
+ if (!target.closest('[data-hcms-field]') && !target.hasAttribute?.('data-hcms-field')) return
68
+ onScalarChange(target, ctx)
69
+ }
70
+
71
+ const onChange = (e) => {
72
+ const target = e.target
73
+ if (!target || !target.closest) return
74
+ if (!target.closest('[data-hcms-form-root]')) return
75
+ if (target.matches('input[type="file"][data-hcms-upload]')) {
76
+ onUploadChange(target, ctx)
77
+ return
78
+ }
79
+ if (target.matches('input[type="checkbox"], input[type="radio"], select')) {
80
+ onScalarChange(target, ctx)
81
+ }
82
+ }
83
+
84
+ const onClick = (e) => {
85
+ const target = e.target
86
+ if (!target || !target.closest) return
87
+ const actionEl = target.closest('[data-hcms-action]')
88
+ if (!actionEl) return
89
+ const action = actionEl.getAttribute('data-hcms-action')
90
+ // add/remove/move must live inside the form root; close inside the shell.
91
+ // Ignore stray data-hcms-action attributes elsewhere on the page.
92
+ if (action === 'add' || action === 'remove' || action === 'move-up' || action === 'move-down' || action === 'clear-upload') {
93
+ if (!actionEl.closest('[data-hcms-form-root]')) return
94
+ } else if (action === 'close') {
95
+ if (!actionEl.closest('[data-hcms-shell]')) return
96
+ }
97
+ if (action === 'add') {
98
+ const arrayEl = actionEl.closest('[data-hcms-path]')
99
+ if (!arrayEl) return
100
+ const arrayPath = arrayEl.getAttribute('data-hcms-path')
101
+ onAdd(arrayPath, ctx)
102
+ } else if (action === 'remove') {
103
+ const itemEl = actionEl.closest('[data-hcms-card], [data-hcms-array-item]')
104
+ if (!itemEl) return
105
+ requestRemove(itemEl, ctx)
106
+ } else if (action === 'move-up' || action === 'move-down') {
107
+ const itemEl = actionEl.closest('[data-hcms-card], [data-hcms-array-item]')
108
+ if (!itemEl) return
109
+ onMove(itemEl, action === 'move-up' ? -1 : 1, ctx)
110
+ } else if (action === 'clear-upload') {
111
+ onClearUpload(actionEl, ctx)
112
+ } else if (action === 'close') {
113
+ ctx.onCloseRequested?.()
114
+ }
115
+ }
116
+
117
+ const doc = formRoot.ownerDocument
118
+ doc.addEventListener('input', onInput, true)
119
+ doc.addEventListener('change', onChange, true)
120
+ doc.addEventListener('click', onClick, true)
121
+
122
+ ctx.detachEvents = () => {
123
+ doc.removeEventListener('input', onInput, true)
124
+ doc.removeEventListener('change', onChange, true)
125
+ doc.removeEventListener('click', onClick, true)
126
+ BOUND.delete(formRoot)
127
+ }
128
+ }
129
+
130
+ // Element PROPERTIES a form field projects to that fire NO MutationRecord, so
131
+ // hyper-undo's observer can't see them (an input's live `value`, a checkbox's
132
+ // `checked`). Text projections ARE observed (characterData) and need nothing
133
+ // here. Kept narrow on purpose: the broader engine DOM_PROPERTIES_WRITE_SET
134
+ // includes observable writes (innerHTML/outerHTML) we must NOT double-record.
135
+ const UNOBSERVED_FIELD_PROPS = new Set(['value', 'checked'])
136
+
137
+ // Resolve the page element + property a scalar field projects to, but ONLY for
138
+ // the unobserved-property case above. `pathStr` is the field's FULL path (the
139
+ // scalar field element carries data-hcms-path = full path, e.g. "name" or
140
+ // "products.0.name"). Returns { el, prop, oldValue } captured from the live page
141
+ // BEFORE the apply, or null when the projection is text, targets an array item
142
+ // (the engine's ctx isn't pageRoot then), or can't be resolved.
143
+ function resolveUnobservedProjection(ctx, pathStr) {
144
+ if (!pathStr) return null
145
+ const fullPath = pathFromString(pathStr)
146
+ // Array-item fields project under a per-item engine ctx, not pageRoot. Resolving
147
+ // pageRoot.querySelector(selector) would grab the wrong item, so skip — the edit
148
+ // simply keeps today's no-undo behavior rather than recording a wrong revert.
149
+ if (fullPath.some((seg) => typeof seg === 'number' || seg === '*')) return null
150
+ const rule = getRuleAtPath(ctx.pageRules, fullPath)
151
+ if (typeof rule !== 'string') return null
152
+ const at = engine.ruleAttrIndex(rule)
153
+ if (at === -1) return null // text projection — observed
154
+ const prop = rule.slice(at + 1)
155
+ if (!UNOBSERVED_FIELD_PROPS.has(prop)) return null
156
+ const selector = rule.slice(0, at)
157
+ const el = selector ? ctx.pageRoot.querySelector(selector) : ctx.pageRoot
158
+ if (!el) return null
159
+ return { el, prop, oldValue: el[prop] }
160
+ }
161
+
162
+ export function onScalarChange(target, ctx) {
163
+ const fieldEl = target.closest('[data-hcms-field]') || target
164
+ const pathStr = fieldEl.closest('[data-hcms-path]')?.getAttribute('data-hcms-path') || ''
165
+ // Capture the projected page value BEFORE commit() overwrites it. Property
166
+ // writes (@value/@checked) leave no MutationRecord, so without recording them
167
+ // explicitly they produce no undo step (text projections are observed).
168
+ const proj = resolveUnobservedProjection(ctx, pathStr)
169
+ commit(extractFormData(ctx), { path: pathStr, structural: false }, ctx)
170
+ if (proj) {
171
+ const u = platform('undo')
172
+ if (u && typeof u.recordValue === 'function') {
173
+ u.recordValue(proj.el, { prop: proj.prop, oldValue: proj.oldValue, newValue: proj.el[proj.prop] })
174
+ }
175
+ }
176
+ }
177
+
178
+ // Crop adapter seam: ALL crop coupling lives here, between the file pick and
179
+ // the upload. A field opts in via data-hcms-crop on the page element (copied
180
+ // onto the built @image field by the form-builder); absent attr or absent
181
+ // quickcrop (standalone page) uploads raw. Returns { file, dataURL? }, or null
182
+ // to abort (crop cancelled / failed) — the caller resets the picker and skips
183
+ // the commit. quickcrop resolves null on cancel (it does NOT reject), so the
184
+ // cancel branch is the null check, not the catch.
185
+ const CROP_OUTPUT = { type: 'image/webp', quality: 0.85, maxWidth: 2048, maxHeight: 2048 }
186
+
187
+ async function maybeCrop(file, fieldEl) {
188
+ const attr = fieldEl && fieldEl.getAttribute ? fieldEl.getAttribute('data-hcms-crop') : null
189
+ if (attr == null) return { file }
190
+ const crop = platform('quickcrop')
191
+ if (typeof crop !== 'function') return { file }
192
+ try {
193
+ // QuickCrop's `auto` modal adapter looks only at window.themodal. clayjs
194
+ // attaches its dialog as clay.modal and deliberately sets no global, so a
195
+ // clayjs page would silently get QuickCrop's own plain dialog. Naming it here
196
+ // keeps QuickCrop free of client knowledge and clayjs free of the global.
197
+ const modal = (typeof window !== 'undefined' && (window.clay?.modal ?? window.themodal)) || 'auto'
198
+ const r = await crop(file, { aspect: parseCropAspect(attr), modal, ...CROP_OUTPUT })
199
+ if (r === null) return null
200
+ return { file: blobToFile(r.blob, file.name), dataURL: r.dataURL }
201
+ } catch (err) {
202
+ showFieldUploadError(fieldEl, (err && err.message) || 'Crop failed')
203
+ return null
204
+ }
205
+ }
206
+
207
+ // "1:1" → 1, "16:9" → 16/9, "free" / "" / unparseable → null (freeform crop).
208
+ // Exported for tests.
209
+ export function parseCropAspect(value) {
210
+ const v = String(value ?? '').trim().toLowerCase()
211
+ if (v === '' || v === 'free') return null
212
+ const m = v.match(/^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)$/)
213
+ if (!m) return null
214
+ const w = parseFloat(m[1])
215
+ const h = parseFloat(m[2])
216
+ if (!w || !h) return null
217
+ return w / h
218
+ }
219
+
220
+ // quickcrop returns a nameless Blob; the legacy uploader rejects raw Blobs and a
221
+ // host keys content-type off the filename extension, so wrap it as a File
222
+ // whose extension matches the encoded mime. Exported for tests.
223
+ export function blobToFile(blob, originalName) {
224
+ const ext = blob.type === 'image/webp' ? '.webp' : blob.type === 'image/jpeg' ? '.jpg' : '.png'
225
+ const base = String(originalName || 'image').replace(/\.[^.]+$/, '')
226
+ try {
227
+ return new File([blob], base + ext, { type: blob.type })
228
+ } catch {
229
+ return blob
230
+ }
231
+ }
232
+
233
+ // A file was picked in an @file/@image widget. Optionally crop, ask the host to
234
+ // store the file, write the resulting URL into the bound img.src / a.href leaf,
235
+ // then commit once. The leaf attribute change is an observed page mutation, so
236
+ // undo gets a clean step with no commitWithUndo. Async; failures surface inline
237
+ // and reset the picker.
238
+ //
239
+ // The upload either stores the file or it does not, and those are not the same
240
+ // as success and failure. Three outcomes, deliberately spelled out:
241
+ //
242
+ // stored write the served URL. The page carries a reference.
243
+ // cannot store here embed the bytes. A document open from a plain file server
244
+ // or a desktop app has nowhere to put a file, and embedding
245
+ // is the RIGHT answer there, not a consolation prize.
246
+ // failed write nothing. Embedding a file the host refused for size
247
+ // or type is the disease this capability exists to cure: it
248
+ // is how a 2 MB photo ends up in the document, in every
249
+ // future save, and in every stored version.
250
+ const EMBED_CODES = new Set(['unsupported', 'payment-required'])
251
+
252
+ export async function onUploadChange(inputEl, ctx) {
253
+ const file = inputEl.files && inputEl.files[0]
254
+ if (!file) return
255
+ const fieldEl = inputEl.closest('[data-hcms-path]')
256
+ if (!fieldEl) return
257
+ const pathStr = fieldEl.getAttribute('data-hcms-path') || ''
258
+
259
+ // A fresh pick is a fresh attempt: clear any stale inline error (e.g. a
260
+ // crop failure from a previous attempt) before running the new flow.
261
+ showFieldUploadError(fieldEl, null)
262
+
263
+ const cropped = await maybeCrop(file, fieldEl)
264
+ if (!cropped || ctx.closed) { resetPicker(inputEl); return }
265
+ const fileLike = cropped.file
266
+ const previewDataUrl = cropped.dataURL || null
267
+
268
+ const upload = platform('upload')
269
+ if (typeof upload !== 'function') {
270
+ // No client at all: a bare HTML file with the CMS dropped in. Same outcome as
271
+ // a host that answers `unsupported`, and silent for the same reason.
272
+ return finishUpload(inputEl, fieldEl, ctx, pathStr, await embedUrl(fileLike, previewDataUrl, fieldEl), fileLike)
273
+ }
274
+
275
+ // The crop, shown immediately on the thumb's own chrome and NOT on the bound
276
+ // leaf. Writing a two-megabyte data URL to the leaf makes it the field's value,
277
+ // and any commit() landing while the upload is in flight would project it
278
+ // straight into the live page.
279
+ showFieldUploadPreview(fieldEl, previewDataUrl)
280
+ setUploadProgress(fieldEl, 0)
281
+
282
+ const controller = beginUpload(ctx)
283
+ let res
284
+ try {
285
+ res = await upload(fileLike, {
286
+ signal: controller?.signal,
287
+ onProgress: ({ percent }) => setUploadProgress(fieldEl, percent),
288
+ })
289
+ } finally {
290
+ endUpload(ctx, controller)
291
+ clearUploadProgress(fieldEl)
292
+ }
293
+
294
+ // The editor may have been closed while the upload was in flight; bail before
295
+ // writing the (now detached) form leaf or committing to the live page.
296
+ if (ctx.closed) { resetPicker(inputEl); return }
297
+ if (res.code === 'aborted') { resetPicker(inputEl); return }
298
+
299
+ if (res.ok) {
300
+ return finishUpload(inputEl, fieldEl, ctx, pathStr, res.uploads[0].url, fileLike)
301
+ }
302
+
303
+ if (!EMBED_CODES.has(res.code)) {
304
+ showFieldUploadError(fieldEl, res.msg || 'Upload failed')
305
+ ctx.dispatch?.('hcms:error', { error: new Error(res.msg || 'Upload failed'), code: res.code, path: pathStr })
306
+ resetPicker(inputEl)
307
+ return
308
+ }
309
+
310
+ // Embedded, and only `payment-required` says so. A person editing a file on
311
+ // their own desktop does not need to be told their file is a file; a person on
312
+ // a host that WOULD store it, for money, is being told why it did not.
313
+ const note = res.code === 'payment-required'
314
+ ? 'This file is stored in the page. Add a paid plan to upload files.'
315
+ : null
316
+ return finishUpload(inputEl, fieldEl, ctx, pathStr, await embedUrl(fileLike, previewDataUrl, fieldEl), fileLike, note)
317
+ }
318
+
319
+ // Write the URL into the leaf and commit once. `url` empty (no FileReader, an
320
+ // unreadable file) means there is nothing to write, and writing '' would clear a
321
+ // picture the person already had.
322
+ //
323
+ // The note goes on AFTER the commit, never before: a successful commit calls
324
+ // setError(ctx, null), which clears every inline slot in the form. Writing the
325
+ // note first leaves the person with an embedded file and no explanation of why.
326
+ function finishUpload(inputEl, fieldEl, ctx, pathStr, url, fileLike, note = null) {
327
+ if (ctx.closed) { resetPicker(inputEl); return }
328
+ showFieldUploadPreview(fieldEl, null)
329
+ showFieldUploadError(fieldEl, null)
330
+ if (!url) { resetPicker(inputEl); return }
331
+ writeUploadLeaf(fieldEl, url, fileLike.name)
332
+ commit(extractFormData(ctx), { path: pathStr, structural: false }, ctx)
333
+ if (note) showFieldUploadError(fieldEl, note, 'info')
334
+ resetPicker(inputEl)
335
+ }
336
+
337
+ // The crop when there was one, otherwise the file's own bytes. Always a data:
338
+ // URL and never blob:, so both paths degrade identically: a blob: URL is dead the
339
+ // moment the page reloads, so the no-crop path used to commit a picture that
340
+ // looked right until the person came back to it.
341
+ async function embedUrl(fileLike, previewDataUrl, fieldEl) {
342
+ return previewDataUrl || (await fileToDataUrl(fileLike, fieldEl))
343
+ }
344
+
345
+ // The FileReader comes from the field's own realm, never from globalThis: a form
346
+ // mounted into another document reads a File belonging to THAT window, and a
347
+ // reader from a different realm rejects it.
348
+ function fileToDataUrl(file, fieldEl) {
349
+ const Reader = fieldEl?.ownerDocument?.defaultView?.FileReader || globalThis.FileReader
350
+ if (!Reader) return Promise.resolve('')
351
+ return new Promise((resolve) => {
352
+ const reader = new Reader()
353
+ reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '')
354
+ reader.onerror = () => resolve('')
355
+ try { reader.readAsDataURL(file) } catch { resolve('') }
356
+ })
357
+ }
358
+
359
+ // In-flight uploads hang off the ctx, so close() tears them down the same way it
360
+ // tears down the observer and the undo subscription. Without this the request
361
+ // keeps running after the editor is gone: harmless to the page, because every
362
+ // write is behind a ctx.closed check, but it holds the connection and the bytes.
363
+ function beginUpload(ctx) {
364
+ if (typeof AbortController !== 'function') return null
365
+ const controller = new AbortController()
366
+ ;(ctx.uploads || (ctx.uploads = new Set())).add(controller)
367
+ return controller
368
+ }
369
+
370
+ function endUpload(ctx, controller) {
371
+ if (controller) ctx.uploads?.delete(controller)
372
+ }
373
+
374
+ // The × on an upload widget. Clears the bound leaf to empty and commits, so the
375
+ // :has()-driven chrome falls back to the empty state. Removes immediately (no
376
+ // consent gate) per the approved design; distinct from the "remove" action,
377
+ // which deletes a whole array item.
378
+ export function onClearUpload(actionEl, ctx) {
379
+ const fieldEl = actionEl.closest('[data-hcms-path]')
380
+ if (!fieldEl) return
381
+ const pathStr = fieldEl.getAttribute('data-hcms-path') || ''
382
+ writeUploadLeaf(fieldEl, '', '')
383
+ const input = fieldEl.querySelector('input[type="file"][data-hcms-upload]')
384
+ if (input) resetPicker(input)
385
+ showFieldUploadError(fieldEl, null)
386
+ commit(extractFormData(ctx), { path: pathStr, structural: false }, ctx)
387
+ }
388
+
389
+ function findUploadLeaf(fieldEl) {
390
+ return fieldEl.querySelector
391
+ ? fieldEl.querySelector('img[data-hcms-field], a[data-hcms-field]')
392
+ : null
393
+ }
394
+
395
+ // Write a URL into the widget's bound leaf: an <img>'s src, or an <a>'s href
396
+ // plus its visible filename text. Empty url clears to the empty state.
397
+ function writeUploadLeaf(fieldEl, url, fileName) {
398
+ const leaf = findUploadLeaf(fieldEl)
399
+ if (!leaf) return
400
+ const tag = (leaf.tagName || '').toUpperCase()
401
+ if (tag === 'IMG') {
402
+ leaf.src = url || ''
403
+ } else if (tag === 'A') {
404
+ leaf.href = url || ''
405
+ leaf.textContent = url ? (fileName || fileNameFromUrl(url)) : ''
406
+ }
407
+ }
408
+
409
+ function resetPicker(inputEl) {
410
+ // Clear the selection so re-picking the same file fires `change` again.
411
+ try { inputEl.value = '' } catch { /* some inputs reject programmatic clear */ }
412
+ }
413
+
414
+ // The at-upload-time picture, painted on the frame rather than assigned to the
415
+ // leaf, because the leaf IS the field's value. CSS reveals the thumb while
416
+ // [data-hcms-uploading] is set, so the preview shows even on a field that is
417
+ // still empty.
418
+ function showFieldUploadPreview(fieldEl, dataUrl) {
419
+ const frame = fieldEl.querySelector ? fieldEl.querySelector('.mirk-image__frame') : null
420
+ if (!frame) return
421
+ if (dataUrl) frame.style.backgroundImage = `url("${dataUrl.replace(/"/g, '%22')}")`
422
+ else frame.style.removeProperty('background-image')
423
+ }
424
+
425
+ // Progress where the person is looking. The percent drives a CSS custom property
426
+ // rather than any markup of its own, so the templates keep their exact shape and
427
+ // a half-finished upload cannot leave an orphan node behind in the form.
428
+ function setUploadProgress(fieldEl, percent) {
429
+ const pct = Math.max(0, Math.min(100, Number(percent) || 0))
430
+ fieldEl.setAttribute('data-hcms-uploading', '')
431
+ fieldEl.style?.setProperty?.('--hcms-upload-progress', `${pct}%`)
432
+ }
433
+
434
+ function clearUploadProgress(fieldEl) {
435
+ fieldEl.removeAttribute('data-hcms-uploading')
436
+ fieldEl.style?.removeProperty?.('--hcms-upload-progress')
437
+ }
438
+
439
+ // `kind` distinguishes a refusal from a note: 'info' is the file that WAS stored,
440
+ // in the page, and the slot says why rather than shouting about a failure.
441
+ function showFieldUploadError(fieldEl, message, kind = 'error') {
442
+ const slot = fieldEl.querySelector ? fieldEl.querySelector(':scope > .hcms-error') : null
443
+ if (!slot) return
444
+ slot.classList.toggle('hcms-error--info', !!message && kind === 'info')
445
+ if (message) {
446
+ slot.textContent = message
447
+ slot.hidden = false
448
+ } else {
449
+ slot.textContent = ''
450
+ slot.hidden = true
451
+ }
452
+ }
453
+
454
+ export function onAdd(arrayPath, ctx) {
455
+ const { formRoot, pageRules } = ctx
456
+ const arrayEl = formRoot.querySelector(`[data-hcms-path="${cssEscape(arrayPath)}"]`)
457
+ if (!arrayEl) throw new Error(`hypercms: no element at path "${arrayPath}"`)
458
+ const slot = arrayEl.querySelector('.hcms-array-items')
459
+ if (!slot) throw new Error(`hypercms: array container missing .hcms-array-items at "${arrayPath}"`)
460
+
461
+ const pathArr = pathFromString(arrayPath)
462
+ const ruleAtPath = ruleAt(pageRules, pathArr)
463
+ const isObjectArray = Array.isArray(ruleAtPath)
464
+ const isScalarArray = typeof ruleAtPath === 'string' && ruleAtPath.endsWith('[]')
465
+ if (!isObjectArray && !isScalarArray) throw new Error(`hypercms: path "${arrayPath}" is not an array`)
466
+
467
+ const maxItems = readIntAttr(arrayEl, 'data-hcms-max-items')
468
+ const existingItems = slot.querySelectorAll(':scope > [data-hcms-card], :scope > [data-hcms-array-item]')
469
+ if (arrayEl.hasAttribute('data-hcms-no-add')) return
470
+ if (maxItems != null && existingItems.length >= maxItems) return
471
+ const nextIndex = existingItems.length
472
+
473
+ const itemShape = isObjectArray ? ruleAtPath[1] : ruleAtPath.replace(/\[\]$/, '')
474
+ const itemData = scaffold(isObjectArray ? itemShape : 'string')
475
+ const itemNode = buildItem({
476
+ shape: isObjectArray ? 'object-array-item' : 'scalar-array-item',
477
+ itemShape,
478
+ pathArr: [...pathArr, nextIndex],
479
+ data: itemData,
480
+ doc: ctx.doc,
481
+ // Component arrays (chips) record their item-template key at build time so
482
+ // added items keep the same chrome.
483
+ itemKey: arrayEl.getAttribute('data-hcms-item-tpl') || null,
484
+ pageRules,
485
+ })
486
+ slot.appendChild(itemNode)
487
+ enhanceFields(itemNode, ctx.doc, ctx.view?.enhanceFormRichText !== false)
488
+ updateArrayButtonsVisibility(arrayEl)
489
+ return commitWithUndo(`Add ${arrayPath}`, () =>
490
+ commit(extractFormData(ctx), { path: arrayPath, structural: true }, ctx)
491
+ )
492
+ }
493
+
494
+ export function onMove(itemEl, direction, ctx) {
495
+ const arrayEl = itemEl.closest('[data-hcms-shape="object-array"], [data-hcms-shape="scalar-array"]')
496
+ if (!arrayEl) return
497
+ if (arrayEl.hasAttribute('data-hcms-no-reorder')) return
498
+ const slot = arrayEl.querySelector('.hcms-array-items')
499
+ if (!slot) return
500
+ const siblings = Array.from(slot.querySelectorAll(':scope > [data-hcms-card], :scope > [data-hcms-array-item]'))
501
+ const idx = siblings.indexOf(itemEl)
502
+ if (idx < 0) return
503
+ const targetIdx = idx + direction
504
+ if (targetIdx < 0 || targetIdx >= siblings.length) return
505
+ const moveAction = itemEl.querySelector(`[data-hcms-action="${direction < 0 ? 'move-up' : 'move-down'}"]`)
506
+ if (direction < 0) {
507
+ slot.insertBefore(itemEl, siblings[targetIdx])
508
+ } else {
509
+ slot.insertBefore(itemEl, siblings[targetIdx].nextSibling)
510
+ }
511
+ restampSiblingPaths(slot)
512
+ updateArrayButtonsVisibility(arrayEl)
513
+ // Restore focus to the moved button so keyboard users can continue pressing
514
+ // the same button (now landed in its new position).
515
+ if (moveAction && typeof moveAction.focus === 'function') {
516
+ const newAction = itemEl.querySelector(`[data-hcms-action="${direction < 0 ? 'move-up' : 'move-down'}"]`)
517
+ newAction?.focus?.()
518
+ }
519
+ return commitWithUndo(`Reorder ${arrayEl.getAttribute('data-hcms-path') || ''}`, () =>
520
+ commit(extractFormData(ctx), { path: arrayEl.getAttribute('data-hcms-path') || '', structural: true }, ctx)
521
+ )
522
+ }
523
+
524
+ const DEFAULT_CONFIRM_REMOVE = 'Delete this item?'
525
+
526
+ // Resolve whether removing an item from `arrayEl` should confirm first, and with
527
+ // what prompt. Most specific wins: a per-array `data-hcms-confirm-remove`
528
+ // attribute (a string overrides the prompt; "off"/"false"/"no"/"0" disables that
529
+ // one array), then the global `confirmRemove` open() option (a string overrides
530
+ // the prompt, true forces every list, false disables everything), then the
531
+ // built-in default. The default confirms card removals (object-array items hold
532
+ // several fields) and leaves scalar-array chips instant. Returns the prompt to
533
+ // confirm with, or null to remove without asking.
534
+ function resolveRemoveConfirm(arrayEl, ctx) {
535
+ const attr = arrayEl && arrayEl.getAttribute('data-hcms-confirm-remove')
536
+ if (attr != null) {
537
+ if (/^(off|false|no|0)$/i.test(attr.trim())) return null
538
+ return attr || DEFAULT_CONFIRM_REMOVE
539
+ }
540
+ const global = ctx && ctx.confirmRemove
541
+ if (global === false) return null
542
+ if (typeof global === 'string') return global || DEFAULT_CONFIRM_REMOVE
543
+ if (global === true) return DEFAULT_CONFIRM_REMOVE
544
+ return arrayEl && arrayEl.getAttribute('data-hcms-shape') === 'object-array'
545
+ ? DEFAULT_CONFIRM_REMOVE
546
+ : null
547
+ }
548
+
549
+ // Built-in delete confirmation: every list-item removal routes through here so
550
+ // the policy applies uniformly. Confirmation uses hyperclay's consent() (the
551
+ // styled modal) when the host page provides it, falls back to native confirm(),
552
+ // and proceeds when neither exists (non-browser). Programmatic api.removeItem()
553
+ // bypasses this by calling onRemove directly, the way undo treats deliberate
554
+ // actions.
555
+ export function requestRemove(itemEl, ctx) {
556
+ const arrayEl = itemEl.closest('[data-hcms-shape="object-array"], [data-hcms-shape="scalar-array"]')
557
+ const message = resolveRemoveConfirm(arrayEl, ctx)
558
+ if (message == null) return onRemove(itemEl, ctx)
559
+ const consent = platform('consent') || (typeof window !== 'undefined' && window.consent)
560
+ if (typeof consent === 'function') {
561
+ Promise.resolve(consent(message)).then(() => {
562
+ // The editor can be closed, or switched to another view, while the
563
+ // confirm is still on screen. ctx is dead by then, but itemEl still
564
+ // points into the detached form and commit would apply that stale form
565
+ // over the live page: the card goes, and every other field reverts to
566
+ // whatever it held when the dialog opened.
567
+ if (ctx.closed) return
568
+ onRemove(itemEl, ctx)
569
+ }, () => {})
570
+ } else if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
571
+ if (window.confirm(message)) onRemove(itemEl, ctx)
572
+ } else {
573
+ onRemove(itemEl, ctx)
574
+ }
575
+ }
576
+
577
+ export function onRemove(itemEl, ctx) {
578
+ const path = itemEl.getAttribute('data-hcms-path') || ''
579
+ const parent = itemEl.parentElement
580
+ const arrayEl = itemEl.closest('[data-hcms-shape="object-array"], [data-hcms-shape="scalar-array"]')
581
+ if (arrayEl?.hasAttribute('data-hcms-no-remove')) return
582
+ if (arrayEl) {
583
+ const minItems = readIntAttr(arrayEl, 'data-hcms-min-items')
584
+ const slot = arrayEl.querySelector('.hcms-array-items')
585
+ const count = slot
586
+ ? slot.querySelectorAll(':scope > [data-hcms-card], :scope > [data-hcms-array-item]').length
587
+ : 0
588
+ if (minItems != null && count <= minItems) return
589
+ }
590
+ itemEl.remove()
591
+ // After removal, re-stamp sibling paths to keep indices contiguous.
592
+ if (parent) restampSiblingPaths(parent)
593
+ if (arrayEl) updateArrayButtonsVisibility(arrayEl)
594
+ return commitWithUndo(`Remove ${path}`, () =>
595
+ commit(extractFormData(ctx), { path, structural: true }, ctx)
596
+ )
597
+ }
598
+
599
+ export function commit(newData, info, ctx) {
600
+ // The last line of defence for any deferred path that reaches a torn-down
601
+ // session: a resolved confirmation, an upload that finished after close, a
602
+ // queued handler. ctx.pageRoot is still the live page.
603
+ //
604
+ // ok:false is deliberate and load-bearing — commitWithUndo records an undo
605
+ // entry on a truthy ok, so returning ok:true here would put a commit that
606
+ // never happened onto the undo stack. Returning before the error branch below
607
+ // is equally deliberate: setError would write into a detached shell.
608
+ if (ctx.closed) return { ok: false, skipped: true, closed: true }
609
+ const fingerprint = stableStringify(newData)
610
+ // Only a plain edit can be skipped on an unchanged fingerprint. A structural
611
+ // operation is never a no-op even when the data is identical: reordering two
612
+ // rows whose ruled fields read the same produces the same JSON, and the page
613
+ // nodes still have to move. Skipping it here meant the form supplied identity
614
+ // the engine never got to use.
615
+ if (!info.structural && fingerprint === ctx.lastFingerprint) {
616
+ return { ok: true, skipped: true }
617
+ }
618
+
619
+ // undefined is a legitimate value here: a rules tree that is nothing but a
620
+ // read-only projection strips to it, and engine.apply writes nothing when the
621
+ // rules are undefined. Only a ctx that never had the field at all — the
622
+ // hand-built stubs in older tests — falls back to the unstripped rules.
623
+ const writeRules = Object.hasOwn(ctx, 'writeRules') ? ctx.writeRules : ctx.pageRules
624
+
625
+ const result = applyWithRollback(ctx.pageRoot, writeRules, newData, {
626
+ observerHandle: ctx.observerHandle,
627
+ shellRoot: ctx.shellRoot,
628
+ structural: !!info.structural,
629
+ structuralPath: info.path || null,
630
+ formRoot: ctx.formRoot,
631
+ })
632
+ if (result.ok) {
633
+ ctx.lastFingerprint = fingerprint
634
+ ctx.lastData = newData
635
+ setError(ctx, null)
636
+ ctx.dispatch?.('hcms:change', { data: newData, path: info.path, structural: !!info.structural })
637
+ ctx.onChange?.(newData, info)
638
+ } else {
639
+ setError(ctx, formatError(result.error, info.path))
640
+ ctx.dispatch?.('hcms:error', { error: result.error, attemptedData: newData })
641
+ ctx.onError?.(result.error)
642
+ }
643
+ return result
644
+ }
645
+
646
+ // Resolve one form leaf by path, and write a value into it. Shared by
647
+ // cms.api.setValue and by the inline view's text commit, which reads the page
648
+ // element richclay just edited and mirrors it into the form before committing.
649
+ export function findLeafField(formRoot, path) {
650
+ const esc = cssEscape(path)
651
+ // Prefer a tag-qualified leaf (input/textarea/select/img/a) — since v0.3
652
+ // stamps data-hcms-field on the wrapping container too, a bare attribute
653
+ // match would resolve to the container and lose the value write.
654
+ const leafSel =
655
+ `[data-hcms-path="${esc}"] input[data-hcms-field], ` +
656
+ `[data-hcms-path="${esc}"] textarea[data-hcms-field], ` +
657
+ `[data-hcms-path="${esc}"] select[data-hcms-field], ` +
658
+ `[data-hcms-path="${esc}"] img[data-hcms-field], ` +
659
+ `[data-hcms-path="${esc}"] a[data-hcms-field], ` +
660
+ `[data-hcms-path="${esc}"] [contenteditable][data-hcms-field], ` +
661
+ // Leaf is the path-stamped element itself (inline-stamped fields):
662
+ `input[data-hcms-path="${esc}"][data-hcms-field], ` +
663
+ `textarea[data-hcms-path="${esc}"][data-hcms-field], ` +
664
+ `select[data-hcms-path="${esc}"][data-hcms-field], ` +
665
+ `img[data-hcms-path="${esc}"][data-hcms-field], ` +
666
+ `a[data-hcms-path="${esc}"][data-hcms-field], ` +
667
+ `[contenteditable][data-hcms-path="${esc}"][data-hcms-field]`
668
+ return formRoot.querySelector(leafSel)
669
+ }
670
+
671
+ export function writeFieldValue(el, value, formRoot, path) {
672
+ const tag = (el.tagName || '').toUpperCase()
673
+ const type = (el.getAttribute('type') || '').toLowerCase()
674
+ if (tag === 'INPUT' && type === 'checkbox') {
675
+ el.checked = value === true || value === 'true'
676
+ return
677
+ }
678
+ if (tag === 'INPUT' && type === 'radio') {
679
+ // Radios sharing the same path act as a group. Toggle the matching option.
680
+ const esc = cssEscape(path)
681
+ const group = formRoot.querySelectorAll(
682
+ `[data-hcms-path="${esc}"][data-hcms-field][type="radio"], [data-hcms-path="${esc}"] [data-hcms-field][type="radio"]`
683
+ )
684
+ if (group.length) {
685
+ group.forEach((r) => { r.checked = String(r.value) === String(value ?? '') })
686
+ } else {
687
+ el.checked = String(el.value) === String(value ?? '')
688
+ }
689
+ return
690
+ }
691
+ if (tag === 'IMG') {
692
+ el.src = value == null ? '' : String(value)
693
+ return
694
+ }
695
+ if (tag === 'A') {
696
+ el.href = value == null ? '' : String(value)
697
+ return
698
+ }
699
+ if (el.hasAttribute && el.hasAttribute('contenteditable')) {
700
+ el.innerHTML = value == null ? '' : String(value)
701
+ return
702
+ }
703
+ if ('value' in el) {
704
+ el.value = value == null ? '' : String(value)
705
+ return
706
+ }
707
+ el.textContent = value == null ? '' : String(value)
708
+ }
709
+
710
+ export function extractFormData(ctx) {
711
+ const raw = engine.extract(ctx.formRoot, ctx.formRules)
712
+ return coerceBooleans(raw, ctx.formRules)
713
+ }
714
+
715
+ // Walks either pageRules OR formRules (both use the same `endsWith('@checked')`
716
+ // semantics) and coerces leaf strings "true" / "false" to booleans. Engine
717
+ // extract stringifies @checked properties; the form layer wants real booleans
718
+ // so writeValue's Boolean(value) doesn't flip "false" → true on hydration.
719
+ export function coerceBooleans(data, rules) {
720
+ if (rules == null || data == null) return data
721
+ if (typeof rules === 'string') {
722
+ if (rules.endsWith('@checked')) {
723
+ return data === true || data === 'true'
724
+ }
725
+ return data
726
+ }
727
+ if (Array.isArray(rules)) {
728
+ if (!Array.isArray(data)) return data
729
+ const [, itemShape] = rules
730
+ return data.map((item) => coerceBooleans(item, itemShape))
731
+ }
732
+ if (typeof rules === 'object') {
733
+ if (typeof data !== 'object' || Array.isArray(data)) return data
734
+ const out = {}
735
+ for (const [k, child] of Object.entries(rules)) {
736
+ out[k] = coerceBooleans(data[k], child)
737
+ }
738
+ return out
739
+ }
740
+ return data
741
+ }
742
+
743
+ export { stableStringify }
744
+
745
+ // Errors render inline when we can match a path to a slot in the form (a `.hcms-error`
746
+ // direct child of the path's container — added by the default scalar / array templates).
747
+ // A single commit can produce multiple mismatches (ShapeMismatch carries an array); each one
748
+ // tries to land at its own path. Custom templates that omit the slot, and path-less errors,
749
+ // fall back to the global banner (joined when there's more than one).
750
+ function setError(ctx, errors) {
751
+ ctx.lastErrors = errors && errors.length ? errors : null
752
+ applyErrorState(ctx)
753
+ }
754
+
755
+ // Re-apply ctx.lastErrors to the DOM. Exported so refreshForm can call this
756
+ // after morphForm rebuilds the form — the morph wipes inline error slots
757
+ // (they're inside the form), so we restore them post-morph.
758
+ export function applyErrorState(ctx) {
759
+ clearInlineErrors(ctx)
760
+ if (ctx.errorEl) {
761
+ ctx.errorEl.textContent = ''
762
+ ctx.errorEl.hidden = true
763
+ }
764
+ if (!ctx.lastErrors) return
765
+
766
+ const unplaced = []
767
+ for (const { message, path } of ctx.lastErrors) {
768
+ if (path != null && path !== '') {
769
+ const target = findInlineErrorSlot(ctx.formRoot, path)
770
+ if (target) {
771
+ // Two errors at the same path concatenate into one slot, separated by a newline.
772
+ target.textContent = target.textContent ? `${target.textContent}\n${message}` : message
773
+ target.hidden = false
774
+ continue
775
+ }
776
+ }
777
+ unplaced.push(message)
778
+ }
779
+
780
+ if (unplaced.length && ctx.errorEl) {
781
+ ctx.errorEl.textContent = unplaced.join('\n')
782
+ ctx.errorEl.hidden = false
783
+ }
784
+ }
785
+
786
+ function clearInlineErrors(ctx) {
787
+ if (!ctx.formRoot) return
788
+ for (const el of ctx.formRoot.querySelectorAll('.hcms-error')) {
789
+ el.textContent = ''
790
+ el.hidden = true
791
+ }
792
+ }
793
+
794
+ function findInlineErrorSlot(formRoot, path) {
795
+ if (!formRoot) return null
796
+ // Walk up the path: try the exact match first, then trim the last segment, etc.
797
+ // Lets deep-path errors land at the nearest ancestor that has a slot — useful when
798
+ // engine paths go deeper than the form's stamped structure (e.g., migration data).
799
+ const segs = path.split('.')
800
+ while (segs.length > 0) {
801
+ const candidate = segs.join('.')
802
+ const esc = (typeof CSS !== 'undefined' && CSS.escape) ? CSS.escape(candidate) : candidate.replace(/[^a-zA-Z0-9_\-.*]/g, (c) => '\\' + c)
803
+ const container = formRoot.querySelector(`[data-hcms-path="${esc}"]`)
804
+ if (container) {
805
+ for (const child of container.children) {
806
+ if (child.classList && child.classList.contains('hcms-error')) return child
807
+ }
808
+ }
809
+ segs.pop()
810
+ }
811
+ return null
812
+ }
813
+
814
+ function formatError(err, fallbackPath) {
815
+ if (!err) return [{ message: 'unknown error', path: fallbackPath }]
816
+ if (err.name === 'EmptyListInsert') {
817
+ return [{ message: 'Add a seed item in HTML first.', path: fallbackPath }]
818
+ }
819
+ if (err.name === 'ShapeMismatch' && Array.isArray(err.mismatches) && err.mismatches.length) {
820
+ return err.mismatches.map((m) => ({
821
+ message: `Shape mismatch: expected ${m.expected}, got ${m.got}`,
822
+ path: m.path,
823
+ }))
824
+ }
825
+ return [{ message: err.message || String(err), path: fallbackPath }]
826
+ }
827
+
828
+ function ruleAt(rules, pathArr) {
829
+ let node = rules
830
+ for (const seg of pathArr) {
831
+ if (node == null) return undefined
832
+ if (typeof node === 'string') return undefined
833
+ if (Array.isArray(node)) {
834
+ if (typeof seg !== 'number' && seg !== '*') return undefined
835
+ node = node[1]
836
+ continue
837
+ }
838
+ if (typeof node === 'object') {
839
+ if (typeof seg === 'number') return undefined
840
+ if (!(seg in node)) return undefined
841
+ node = node[seg]
842
+ continue
843
+ }
844
+ return undefined
845
+ }
846
+ return node
847
+ }
848
+
849
+ function readIntAttr(el, name) {
850
+ if (!el || !el.hasAttribute(name)) return null
851
+ const n = parseInt(el.getAttribute(name), 10)
852
+ return Number.isFinite(n) ? n : null
853
+ }
854
+
855
+ export function updateArrayButtonsVisibility(arrayEl) {
856
+ if (!arrayEl) return
857
+ const slot = arrayEl.querySelector('.hcms-array-items')
858
+ if (!slot) return
859
+ const items = Array.from(slot.querySelectorAll(':scope > [data-hcms-card], :scope > [data-hcms-array-item]'))
860
+ const count = items.length
861
+ const max = readIntAttr(arrayEl, 'data-hcms-max-items')
862
+ const min = readIntAttr(arrayEl, 'data-hcms-min-items')
863
+ const noAdd = arrayEl.hasAttribute('data-hcms-no-add')
864
+ const noRemove = arrayEl.hasAttribute('data-hcms-no-remove')
865
+ const noReorder = arrayEl.hasAttribute('data-hcms-no-reorder')
866
+ const addBtn = arrayEl.querySelector(':scope > .hcms-add, :scope > * > .hcms-add, :scope > [data-hcms-action="add"]')
867
+ if (addBtn) addBtn.hidden = noAdd || (max != null && count >= max)
868
+ items.forEach((item, i) => {
869
+ const rm = item.querySelector('[data-hcms-action="remove"]')
870
+ if (rm) rm.hidden = noRemove || (min != null && count <= min)
871
+ const up = item.querySelector('[data-hcms-action="move-up"]')
872
+ if (up) up.hidden = noReorder || i === 0
873
+ const dn = item.querySelector('[data-hcms-action="move-down"]')
874
+ if (dn) dn.hidden = noReorder || i === count - 1
875
+ })
876
+ }
877
+
878
+ export function restampAllSiblings(formRoot) {
879
+ if (!formRoot || !formRoot.querySelectorAll) return
880
+ formRoot.querySelectorAll('.hcms-array-items').forEach((slot) => restampSiblingPaths(slot))
881
+ }
882
+
883
+ function restampSiblingPaths(parent) {
884
+ // Radio names are rewritten one item at a time below, so during a move or
885
+ // drag a just-renamed radio can transiently share a name with a
886
+ // not-yet-renamed sibling — and the browser enforces single-selection per
887
+ // group AT THAT INSTANT, permanently unchecking one of them (null extract →
888
+ // the whole-form commit clobbers that field's page value). Snapshot every
889
+ // radio's checked state and re-assert it once all names are final (and
890
+ // unique per field) again.
891
+ const radios = parent.querySelectorAll
892
+ ? Array.from(parent.querySelectorAll('input[type="radio"][data-hcms-field]'), (r) => [r, r.checked])
893
+ : []
894
+ let i = 0
895
+ for (const child of parent.children) {
896
+ if (!child.matches?.('[data-hcms-card], [data-hcms-array-item]')) continue
897
+ const path = child.getAttribute('data-hcms-path')
898
+ if (!path) continue
899
+ const segs = path.split('.')
900
+ segs[segs.length - 1] = String(i)
901
+ const newPath = segs.join('.')
902
+ if (newPath !== path) restampSubtree(child, path, newPath)
903
+ i++
904
+ }
905
+ for (const [r, wasChecked] of radios) {
906
+ if (r.checked !== wasChecked) r.checked = wasChecked
907
+ }
908
+ }
909
+
910
+ function restampSubtree(root, oldPrefix, newPrefix) {
911
+ const all = root.querySelectorAll('[data-hcms-path]')
912
+ root.setAttribute('data-hcms-path', newPrefix)
913
+ for (const el of all) {
914
+ const p = el.getAttribute('data-hcms-path')
915
+ if (p === oldPrefix) {
916
+ el.setAttribute('data-hcms-path', newPrefix)
917
+ } else if (p && p.startsWith(oldPrefix + '.')) {
918
+ el.setAttribute('data-hcms-path', newPrefix + p.slice(oldPrefix.length))
919
+ }
920
+ }
921
+ syncRadioGroupNames(root)
922
+ }
923
+
924
+ // Radio group names derive from the field path at build time (populateOptions),
925
+ // so a restamp that moves paths must recompute them. Otherwise a removed-then-
926
+ // re-added sibling gets a fresh name that collides with this item's stale one:
927
+ // the two fields share a browser radio group, checking one unchecks the other,
928
+ // and the whole-form commit clobbers the unchecked field's page value. Only
929
+ // names the builder minted ('hcms-' prefix) are touched — radios in inline or
930
+ // custom templates keep whatever name the author gave them.
931
+ function syncRadioGroupNames(root) {
932
+ for (const input of root.querySelectorAll('input[type="radio"][data-hcms-field]')) {
933
+ if (!input.name || !input.name.startsWith('hcms-')) continue
934
+ const host = input.closest('[data-hcms-path]')
935
+ if (host) input.name = radioGroupName(host.getAttribute('data-hcms-path'))
936
+ }
937
+ }
938
+
939
+ function stableStringify(value) {
940
+ return JSON.stringify(value, (_, v) => {
941
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
942
+ // Use a null-prototype container so `__proto__` keys survive sorting and
943
+ // serialize as data rather than mutating object prototype.
944
+ const sorted = Object.create(null)
945
+ for (const k of Object.keys(v).sort()) sorted[k] = v[k]
946
+ return sorted
947
+ }
948
+ return v
949
+ })
950
+ }
951
+
952
+ export function cssEscape(value) {
953
+ if (typeof CSS !== 'undefined' && CSS.escape) return CSS.escape(value)
954
+ return String(value).replace(/[^a-zA-Z0-9_\-.*]/g, (c) => '\\' + c)
955
+ }