@elytracms/studio 0.0.91 → 0.0.93

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 (353) hide show
  1. package/convex/asset-alt-migration.test.ts +127 -0
  2. package/convex/asset-replace.test.ts +168 -0
  3. package/convex/asset-revalidate.test.ts +6 -6
  4. package/convex/assets.ts +41 -5
  5. package/convex/cms.ts +537 -177
  6. package/convex/contract-parity.test.ts +173 -19
  7. package/convex/delivery.ts +26 -13
  8. package/convex/document-revalidate.test.ts +17 -16
  9. package/convex/orphan-hydration.test.ts +116 -0
  10. package/convex/presence-cost.test.ts +89 -0
  11. package/convex/rail-write-parity.test.ts +17 -14
  12. package/convex/referenceMaintenance.ts +15 -15
  13. package/convex/schema.ts +70 -5
  14. package/convex/triggers.ts +21 -8
  15. package/convex/write-limits.ts +1 -1
  16. package/dist/components/ai/ai-chat-panel.js +33 -22
  17. package/dist/components/ai/ai-chat-panel.js.map +1 -1
  18. package/dist/components/ai/proposal-card.js +2 -1
  19. package/dist/components/ai/proposal-card.js.map +1 -1
  20. package/dist/components/ai/translate-document-action.d.ts +8 -2
  21. package/dist/components/ai/translate-document-action.js.map +1 -1
  22. package/dist/components/ai/translate-document-dialog.d.ts +1 -1
  23. package/dist/components/ai/translate-document-dialog.js +14 -8
  24. package/dist/components/ai/translate-document-dialog.js.map +1 -1
  25. package/dist/components/ai/translate-skipped.d.ts +19 -0
  26. package/dist/components/ai/translate-skipped.js +46 -0
  27. package/dist/components/ai/translate-skipped.js.map +1 -0
  28. package/dist/components/app-views/default-modules.d.ts +23 -6
  29. package/dist/components/app-views/default-modules.js +33 -14
  30. package/dist/components/app-views/default-modules.js.map +1 -1
  31. package/dist/components/assets/asset-detail.js +18 -8
  32. package/dist/components/assets/asset-detail.js.map +1 -1
  33. package/dist/components/assets/asset-metadata.d.ts +15 -4
  34. package/dist/components/assets/asset-metadata.js +15 -2
  35. package/dist/components/assets/asset-metadata.js.map +1 -1
  36. package/dist/components/assets/asset-search.js +4 -1
  37. package/dist/components/assets/asset-search.js.map +1 -1
  38. package/dist/components/assets/asset-selection.d.ts +0 -8
  39. package/dist/components/assets/asset-selection.js +4 -23
  40. package/dist/components/assets/asset-selection.js.map +1 -1
  41. package/dist/components/assets/asset-thumb.js +3 -2
  42. package/dist/components/assets/asset-thumb.js.map +1 -1
  43. package/dist/components/assets/assets-collection-view-source.d.ts +3 -3
  44. package/dist/components/assets/assets-collection-view-source.js +9 -5
  45. package/dist/components/assets/assets-collection-view-source.js.map +1 -1
  46. package/dist/components/assets/assets-store.d.ts +14 -0
  47. package/dist/components/assets/assets-store.js +23 -1
  48. package/dist/components/assets/assets-store.js.map +1 -1
  49. package/dist/components/assets/replace-file.d.ts +22 -0
  50. package/dist/components/assets/replace-file.js +59 -0
  51. package/dist/components/assets/replace-file.js.map +1 -0
  52. package/dist/components/builder/blocks-model.d.ts +14 -26
  53. package/dist/components/builder/blocks-model.js +35 -41
  54. package/dist/components/builder/blocks-model.js.map +1 -1
  55. package/dist/components/builder/blocks-panel.d.ts +7 -3
  56. package/dist/components/builder/blocks-panel.js +6 -5
  57. package/dist/components/builder/blocks-panel.js.map +1 -1
  58. package/dist/components/builder/builder-store.d.ts +126 -30
  59. package/dist/components/builder/builder-store.js +169 -67
  60. package/dist/components/builder/builder-store.js.map +1 -1
  61. package/dist/components/builder/canvas-body.d.ts +30 -0
  62. package/dist/components/builder/canvas-body.js +38 -0
  63. package/dist/components/builder/canvas-body.js.map +1 -0
  64. package/dist/components/builder/canvas-chrome-css.js +7 -0
  65. package/dist/components/builder/canvas-chrome-css.js.map +1 -1
  66. package/dist/components/builder/canvas-dnd.d.ts +7 -7
  67. package/dist/components/builder/canvas-dnd.js +5 -5
  68. package/dist/components/builder/canvas-dnd.js.map +1 -1
  69. package/dist/components/builder/canvas-render-context.d.ts +30 -22
  70. package/dist/components/builder/canvas-render-context.js +44 -44
  71. package/dist/components/builder/canvas-render-context.js.map +1 -1
  72. package/dist/components/builder/canvas-runs.d.ts +45 -0
  73. package/dist/components/builder/canvas-runs.js +51 -0
  74. package/dist/components/builder/canvas-runs.js.map +1 -0
  75. package/dist/components/builder/canvas.d.ts +5 -4
  76. package/dist/components/builder/canvas.js +34 -6
  77. package/dist/components/builder/canvas.js.map +1 -1
  78. package/dist/components/builder/clipboard-actions.d.ts +15 -0
  79. package/dist/components/builder/clipboard-actions.js +50 -13
  80. package/dist/components/builder/clipboard-actions.js.map +1 -1
  81. package/dist/components/builder/composition-field-editor.d.ts +10 -16
  82. package/dist/components/builder/composition-field-editor.js +30 -40
  83. package/dist/components/builder/composition-field-editor.js.map +1 -1
  84. package/dist/components/builder/composition-patches.d.ts +7 -0
  85. package/dist/components/builder/composition-patches.js +256 -0
  86. package/dist/components/builder/composition-patches.js.map +1 -0
  87. package/dist/components/builder/delete-key.d.ts +5 -0
  88. package/dist/components/builder/delete-key.js +4 -0
  89. package/dist/components/builder/delete-key.js.map +1 -1
  90. package/dist/components/builder/insert-context.d.ts +17 -25
  91. package/dist/components/builder/insert-context.js +45 -92
  92. package/dist/components/builder/insert-context.js.map +1 -1
  93. package/dist/components/builder/page-tree.d.ts +3 -3
  94. package/dist/components/builder/page-tree.js +3 -3
  95. package/dist/components/builder/page-tree.js.map +1 -1
  96. package/dist/components/builder/placement-refusal.d.ts +18 -0
  97. package/dist/components/builder/placement-refusal.js +46 -0
  98. package/dist/components/builder/placement-refusal.js.map +1 -0
  99. package/dist/components/builder/property-inspector.js +4 -0
  100. package/dist/components/builder/property-inspector.js.map +1 -1
  101. package/dist/components/builder/reuse-context.js +65 -26
  102. package/dist/components/builder/reuse-context.js.map +1 -1
  103. package/dist/components/builder/reuse.d.ts +79 -8
  104. package/dist/components/builder/reuse.js +71 -23
  105. package/dist/components/builder/reuse.js.map +1 -1
  106. package/dist/components/builder/run-inspector.d.ts +11 -0
  107. package/dist/components/builder/run-inspector.js +33 -0
  108. package/dist/components/builder/run-inspector.js.map +1 -0
  109. package/dist/components/cms/asset-field.d.ts +38 -1
  110. package/dist/components/cms/asset-field.js +182 -58
  111. package/dist/components/cms/asset-field.js.map +1 -1
  112. package/dist/components/cms/asset-picker-core.d.ts +16 -0
  113. package/dist/components/cms/asset-picker-core.js +20 -0
  114. package/dist/components/cms/asset-picker-core.js.map +1 -1
  115. package/dist/components/cms/asset-picker.js +12 -18
  116. package/dist/components/cms/asset-picker.js.map +1 -1
  117. package/dist/components/cms/bulk-actions.d.ts +4 -20
  118. package/dist/components/cms/bulk-actions.js +3 -24
  119. package/dist/components/cms/bulk-actions.js.map +1 -1
  120. package/dist/components/cms/cell-edit.d.ts +5 -5
  121. package/dist/components/cms/cell-edit.js.map +1 -1
  122. package/dist/components/cms/cell.js.map +1 -1
  123. package/dist/components/cms/cms-store.d.ts +87 -45
  124. package/dist/components/cms/cms-store.js +347 -203
  125. package/dist/components/cms/cms-store.js.map +1 -1
  126. package/dist/components/cms/collection-table.js +7 -2
  127. package/dist/components/cms/collection-table.js.map +1 -1
  128. package/dist/components/cms/commit-policy.d.ts +24 -0
  129. package/dist/components/cms/commit-policy.js +37 -0
  130. package/dist/components/cms/commit-policy.js.map +1 -0
  131. package/dist/components/cms/detail-editor.js +83 -137
  132. package/dist/components/cms/detail-editor.js.map +1 -1
  133. package/dist/components/cms/document-diff-view.d.ts +3 -1
  134. package/dist/components/cms/document-diff-view.js +12 -7
  135. package/dist/components/cms/document-diff-view.js.map +1 -1
  136. package/dist/components/cms/draft-commit.d.ts +70 -0
  137. package/dist/components/cms/draft-commit.js +82 -0
  138. package/dist/components/cms/draft-commit.js.map +1 -0
  139. package/dist/components/cms/duplicate-document.d.ts +11 -13
  140. package/dist/components/cms/duplicate-document.js +26 -34
  141. package/dist/components/cms/duplicate-document.js.map +1 -1
  142. package/dist/components/cms/field-anchors.d.ts +7 -4
  143. package/dist/components/cms/field-anchors.js +9 -7
  144. package/dist/components/cms/field-anchors.js.map +1 -1
  145. package/dist/components/cms/field-collaboration.d.ts +47 -0
  146. package/dist/components/cms/field-collaboration.js +87 -0
  147. package/dist/components/cms/field-collaboration.js.map +1 -0
  148. package/dist/components/cms/field-control-lib.d.ts +22 -1
  149. package/dist/components/cms/field-control-lib.js +33 -4
  150. package/dist/components/cms/field-control-lib.js.map +1 -1
  151. package/dist/components/cms/field-control.js +17 -14
  152. package/dist/components/cms/field-control.js.map +1 -1
  153. package/dist/components/cms/gate-validation.d.ts +21 -30
  154. package/dist/components/cms/gate-validation.js +11 -22
  155. package/dist/components/cms/gate-validation.js.map +1 -1
  156. package/dist/components/cms/object-array-field.js +4 -4
  157. package/dist/components/cms/object-array-field.js.map +1 -1
  158. package/dist/components/cms/prop-fields.d.ts +7 -2
  159. package/dist/components/cms/prop-fields.js +13 -6
  160. package/dist/components/cms/prop-fields.js.map +1 -1
  161. package/dist/components/cms/reference-confirm.js +10 -4
  162. package/dist/components/cms/reference-confirm.js.map +1 -1
  163. package/dist/components/cms/rich-text/embed-picker.d.ts +1 -1
  164. package/dist/components/cms/rich-text/embed-picker.js.map +1 -1
  165. package/dist/components/cms/rich-text/insert.d.ts +20 -9
  166. package/dist/components/cms/rich-text/insert.js +25 -17
  167. package/dist/components/cms/rich-text/insert.js.map +1 -1
  168. package/dist/components/cms/use-document-draft.d.ts +96 -0
  169. package/dist/components/cms/use-document-draft.js +283 -0
  170. package/dist/components/cms/use-document-draft.js.map +1 -0
  171. package/dist/components/cms/use-document-versions.js +2 -2
  172. package/dist/components/cms/use-document-versions.js.map +1 -1
  173. package/dist/components/cms/use-live-url.d.ts +6 -12
  174. package/dist/components/cms/use-live-url.js +16 -15
  175. package/dist/components/cms/use-live-url.js.map +1 -1
  176. package/dist/components/cms/use-publish-controller.d.ts +13 -18
  177. package/dist/components/cms/use-publish-controller.js +24 -41
  178. package/dist/components/cms/use-publish-controller.js.map +1 -1
  179. package/dist/components/cms/use-published-perspective.d.ts +2 -3
  180. package/dist/components/cms/use-published-perspective.js +4 -3
  181. package/dist/components/cms/use-published-perspective.js.map +1 -1
  182. package/dist/components/cms/versions-pane.js +8 -1
  183. package/dist/components/cms/versions-pane.js.map +1 -1
  184. package/dist/components/collection-view/cms-collection-view-source.js +24 -7
  185. package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
  186. package/dist/components/components/transfer-dialog.js +28 -23
  187. package/dist/components/components/transfer-dialog.js.map +1 -1
  188. package/dist/components/components/transfer.d.ts +31 -21
  189. package/dist/components/components/transfer.js +49 -35
  190. package/dist/components/components/transfer.js.map +1 -1
  191. package/dist/components/project-list.js.map +1 -1
  192. package/dist/components/project-session.d.ts +6 -3
  193. package/dist/components/project-session.js +14 -8
  194. package/dist/components/project-session.js.map +1 -1
  195. package/dist/components/project-switcher.js.map +1 -1
  196. package/dist/components/rail-view-source.js +2 -1
  197. package/dist/components/rail-view-source.js.map +1 -1
  198. package/dist/components/section-nav.js +1 -1
  199. package/dist/components/section-nav.js.map +1 -1
  200. package/dist/lib/ai/apply-content.js +56 -116
  201. package/dist/lib/ai/apply-content.js.map +1 -1
  202. package/dist/lib/ai/chat-context.d.ts +8 -0
  203. package/dist/lib/ai/chat-context.js +37 -6
  204. package/dist/lib/ai/chat-context.js.map +1 -1
  205. package/dist/lib/ai/chat-state.d.ts +14 -1
  206. package/dist/lib/ai/chat-state.js.map +1 -1
  207. package/dist/lib/ai/constraints.d.ts +20 -5
  208. package/dist/lib/ai/constraints.js +125 -33
  209. package/dist/lib/ai/constraints.js.map +1 -1
  210. package/dist/lib/ai/fake-provider.d.ts +16 -3
  211. package/dist/lib/ai/fake-provider.js +22 -6
  212. package/dist/lib/ai/fake-provider.js.map +1 -1
  213. package/dist/lib/ai/index.d.ts +2 -0
  214. package/dist/lib/ai/index.js +2 -0
  215. package/dist/lib/ai/index.js.map +1 -1
  216. package/dist/lib/ai/leash.d.ts +34 -0
  217. package/dist/lib/ai/leash.js +28 -0
  218. package/dist/lib/ai/leash.js.map +1 -0
  219. package/dist/lib/ai/provenance.d.ts +1 -1
  220. package/dist/lib/ai/provenance.js.map +1 -1
  221. package/dist/lib/ai/rich-text-input.d.ts +8 -8
  222. package/dist/lib/ai/rich-text-input.js +2 -1
  223. package/dist/lib/ai/rich-text-input.js.map +1 -1
  224. package/dist/lib/ai/rich-text-translate.d.ts +27 -4
  225. package/dist/lib/ai/rich-text-translate.js +28 -10
  226. package/dist/lib/ai/rich-text-translate.js.map +1 -1
  227. package/dist/lib/ai/shape.d.ts +13 -13
  228. package/dist/lib/ai/shape.js +69 -93
  229. package/dist/lib/ai/shape.js.map +1 -1
  230. package/dist/lib/ai/tools.d.ts +36 -41
  231. package/dist/lib/ai/tools.js +85 -87
  232. package/dist/lib/ai/tools.js.map +1 -1
  233. package/dist/lib/ai/translate-document.d.ts +39 -5
  234. package/dist/lib/ai/translate-document.js +160 -51
  235. package/dist/lib/ai/translate-document.js.map +1 -1
  236. package/dist/lib/ai/types.d.ts +34 -23
  237. package/dist/lib/ai/types.js.map +1 -1
  238. package/dist/lib/ai/update-fields.d.ts +21 -12
  239. package/dist/lib/ai/update-fields.js +118 -177
  240. package/dist/lib/ai/update-fields.js.map +1 -1
  241. package/dist/lib/ai/writer-form.d.ts +116 -0
  242. package/dist/lib/ai/writer-form.js +429 -0
  243. package/dist/lib/ai/writer-form.js.map +1 -0
  244. package/dist/lib/ai-current-document.d.ts +11 -11
  245. package/dist/lib/ai-current-document.js +4 -4
  246. package/dist/lib/ai-current-document.js.map +1 -1
  247. package/dist/lib/assets/upload.d.ts +31 -2
  248. package/dist/lib/assets/upload.js +61 -6
  249. package/dist/lib/assets/upload.js.map +1 -1
  250. package/dist/lib/clipboard/os-clipboard.d.ts +10 -2
  251. package/dist/lib/clipboard/os-clipboard.js +8 -4
  252. package/dist/lib/clipboard/os-clipboard.js.map +1 -1
  253. package/dist/lib/clipboard/paste-target.d.ts +18 -9
  254. package/dist/lib/clipboard/paste-target.js +8 -7
  255. package/dist/lib/clipboard/paste-target.js.map +1 -1
  256. package/dist/lib/cms/field-patch.d.ts +15 -0
  257. package/dist/lib/cms/field-patch.js +25 -0
  258. package/dist/lib/cms/field-patch.js.map +1 -0
  259. package/dist/lib/cms/published-documents.d.ts +1 -1
  260. package/dist/lib/cms/published-documents.js +1 -1
  261. package/dist/lib/cms/published-documents.js.map +1 -1
  262. package/dist/lib/cms-views/view-model.d.ts +3 -3
  263. package/dist/lib/collection-view/__fixtures__/document-writes.d.ts +6 -0
  264. package/dist/lib/collection-view/__fixtures__/document-writes.js +23 -0
  265. package/dist/lib/collection-view/__fixtures__/document-writes.js.map +1 -0
  266. package/dist/lib/collection-view/collection-view-source.d.ts +69 -6
  267. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  268. package/dist/lib/data/collection-content.d.ts +4 -13
  269. package/dist/lib/data/collection-content.js +11 -5
  270. package/dist/lib/data/collection-content.js.map +1 -1
  271. package/dist/lib/dnd/drop-rules.d.ts +25 -31
  272. package/dist/lib/dnd/drop-rules.js +53 -59
  273. package/dist/lib/dnd/drop-rules.js.map +1 -1
  274. package/dist/lib/engine-version.d.ts +1 -1
  275. package/dist/lib/engine-version.js +1 -1
  276. package/dist/lib/engine-version.js.map +1 -1
  277. package/dist/lib/i18n/messages.de.js +72 -15
  278. package/dist/lib/i18n/messages.de.js.map +1 -1
  279. package/dist/lib/i18n/messages.en.d.ts +61 -12
  280. package/dist/lib/i18n/messages.en.js +76 -16
  281. package/dist/lib/i18n/messages.en.js.map +1 -1
  282. package/dist/lib/operations/__fixtures__/write-document.d.ts +30 -0
  283. package/dist/lib/operations/__fixtures__/write-document.js +16 -0
  284. package/dist/lib/operations/__fixtures__/write-document.js.map +1 -0
  285. package/dist/lib/operations/cms-persistence.d.ts +124 -31
  286. package/dist/lib/operations/cms-persistence.js +447 -68
  287. package/dist/lib/operations/cms-persistence.js.map +1 -1
  288. package/dist/lib/operations/config-manifest-lookup.d.ts +35 -0
  289. package/dist/lib/operations/config-manifest-lookup.js +56 -0
  290. package/dist/lib/operations/config-manifest-lookup.js.map +1 -0
  291. package/dist/lib/operations/project-runtime-config.d.ts +39 -0
  292. package/dist/lib/operations/project-runtime-config.js +54 -0
  293. package/dist/lib/operations/project-runtime-config.js.map +1 -0
  294. package/dist/lib/operations/publish-with-revision.d.ts +64 -0
  295. package/dist/lib/operations/publish-with-revision.js +66 -0
  296. package/dist/lib/operations/publish-with-revision.js.map +1 -0
  297. package/dist/lib/operations/runtime-validator.d.ts +32 -0
  298. package/dist/lib/operations/runtime-validator.js +18 -0
  299. package/dist/lib/operations/runtime-validator.js.map +1 -0
  300. package/dist/lib/operations/session-hydration.d.ts +31 -14
  301. package/dist/lib/operations/session-hydration.js +12 -16
  302. package/dist/lib/operations/session-hydration.js.map +1 -1
  303. package/dist/lib/operations/studio-operations.d.ts +59 -2
  304. package/dist/lib/operations/studio-operations.js +76 -14
  305. package/dist/lib/operations/studio-operations.js.map +1 -1
  306. package/dist/lib/persistence/convex-adapter.d.ts +13 -5
  307. package/dist/lib/persistence/convex-adapter.js +189 -74
  308. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  309. package/dist/lib/persistence/convex-client.d.ts +65 -5
  310. package/dist/lib/persistence/convex-client.js +106 -3
  311. package/dist/lib/persistence/convex-client.js.map +1 -1
  312. package/dist/lib/persistence/index.js +17 -18
  313. package/dist/lib/persistence/index.js.map +1 -1
  314. package/dist/lib/persona/labels.js +4 -1
  315. package/dist/lib/persona/labels.js.map +1 -1
  316. package/dist/lib/query/operations-query.d.ts +1 -1
  317. package/dist/lib/query/operations-query.js +1 -1
  318. package/dist/lib/query/operations-query.js.map +1 -1
  319. package/dist/lib/references/impact.d.ts +21 -15
  320. package/dist/lib/references/impact.js +13 -26
  321. package/dist/lib/references/impact.js.map +1 -1
  322. package/dist/lib/references/references-store.d.ts +9 -0
  323. package/dist/lib/references/references-store.js +24 -4
  324. package/dist/lib/references/references-store.js.map +1 -1
  325. package/dist/lib/routes/route-issues.d.ts +6 -6
  326. package/dist/lib/routes/route-issues.js +26 -80
  327. package/dist/lib/routes/route-issues.js.map +1 -1
  328. package/dist/lib/workspace/config-issues.js +2 -1
  329. package/dist/lib/workspace/config-issues.js.map +1 -1
  330. package/dist/lib/workspace/index.d.ts +31 -1
  331. package/dist/lib/workspace/index.js +64 -17
  332. package/dist/lib/workspace/index.js.map +1 -1
  333. package/dist/lib/workspace/switcher-source.d.ts +3 -3
  334. package/dist/lib/workspace/switcher-source.js.map +1 -1
  335. package/dist/lib/workspace/workspace-source.d.ts +2 -2
  336. package/dist/lib/workspace/workspace-source.js +2 -1
  337. package/dist/lib/workspace/workspace-source.js.map +1 -1
  338. package/package.json +3 -3
  339. package/dist/components/cms/cms-composition-validators.d.ts +0 -16
  340. package/dist/components/cms/cms-composition-validators.js +0 -133
  341. package/dist/components/cms/cms-composition-validators.js.map +0 -1
  342. package/dist/components/cms/publish-clean.d.ts +0 -28
  343. package/dist/components/cms/publish-clean.js +0 -38
  344. package/dist/components/cms/publish-clean.js.map +0 -1
  345. package/dist/components/cms/use-save-mode.d.ts +0 -35
  346. package/dist/components/cms/use-save-mode.js +0 -54
  347. package/dist/components/cms/use-save-mode.js.map +0 -1
  348. package/dist/lib/assets/asset-placements.d.ts +0 -28
  349. package/dist/lib/assets/asset-placements.js +0 -83
  350. package/dist/lib/assets/asset-placements.js.map +0 -1
  351. package/dist/lib/cms/document-gate.d.ts +0 -47
  352. package/dist/lib/cms/document-gate.js +0 -61
  353. package/dist/lib/cms/document-gate.js.map +0 -1
@@ -1,3 +1,4 @@
1
+ import { slugFieldOf } from '@elytracms/core/cms-core';
1
2
  /**
2
3
  * EC-442 — the duplicate-document derivation (pure core) + the source-composed
3
4
  * dispatch behind the list row `⋮` menu's and the detail-editor header's
@@ -24,22 +25,13 @@
24
25
  * derivation never blocks a create.
25
26
  * - Composition (`blocks`) field values copy VERBATIM (deep clone): node ids
26
27
  * only need uniqueness within one composition value (`validateCompositionValue`
27
- * checks duplicates per value; the builder's fresh-id counter re-derives from
28
- * the open value via `maxNodeIdSeq`), so identical node ids across two
29
- * documents collide nowhere.
28
+ * checks duplicates per value; the builder mints fresh ids from the injected
29
+ * UUID factory, EC-578), so identical node ids across two documents collide
30
+ * nowhere.
30
31
  * - References/relations copy as-is (the duplicate points at the same targets);
31
32
  * the EC-277 in-transaction reference derivation indexes the new document on
32
33
  * its first persisted write because it catches every writer.
33
34
  */
34
- /** The conventional slug field name (mirrors `field-control`'s slug auto-upgrade). */
35
- const SLUG_FIELD_NAME = 'slug';
36
- /** The built-in control name that opts any `text` field into slug behaviour (EC-315). */
37
- const SLUG_CONTROL = 'slug';
38
- /** The EC-315 slug-field convention: a `text` field named `slug`, or any `text`
39
- * field with `form.control: 'slug'` (same predicate as the studio slug control). */
40
- function isSlugField(field) {
41
- return (field.type === 'text' && (field.form?.control === SLUG_CONTROL || field.name === SLUG_FIELD_NAME));
42
- }
43
35
  /**
44
36
  * Every slug string the collection already holds for `fieldName` — the base
45
37
  * `values` slot AND every per-locale slot of every sibling (the original
@@ -103,39 +95,39 @@ export function deriveDuplicateDocument({ original, newId, schema, siblings, })
103
95
  ...(original.localized !== undefined ? { localized: structuredClone(original.localized) } : {}),
104
96
  // `alias` deliberately dropped (unique slot handle — see module doc).
105
97
  };
106
- for (const field of schema.fields) {
107
- if (!isSlugField(field))
108
- continue;
109
- const pool = slugPool(field.name, siblings);
110
- uniquifySlot(duplicate.values, field.name, pool);
98
+ // EC-590: the collection's SLUG ROLE (`slugField`, default a text field named `slug`)
99
+ // — the one field routing reads, so the one a duplicate must make unique.
100
+ const slugField = slugFieldOf(schema)?.name;
101
+ if (slugField !== undefined) {
102
+ const pool = slugPool(slugField, siblings);
103
+ uniquifySlot(duplicate.values, slugField, pool);
111
104
  for (const localeValues of Object.values(duplicate.localized ?? {})) {
112
- uniquifySlot(localeValues, field.name, pool);
105
+ uniquifySlot(localeValues, slugField, pool);
113
106
  }
114
107
  }
115
108
  return duplicate;
116
109
  }
117
110
  /**
118
111
  * Duplicate `original` through a {@link CollectionViewSource} — the ONE dispatch
119
- * both surfaces (list row menu, detail-editor header) share. Composes the seam's
120
- * EXISTING write paths, never a parallel one (AD-17):
112
+ * both surfaces (list row menu, detail-editor header) share (EC-595 A2):
121
113
  *
122
- * 1. `source.create()` mints the fresh id + inserts the rail's blank record
123
- * (the same collision-free mint the New button uses);
124
- * 2. `source.save(derived)` lands the copied values under that id (the CMS
125
- * save is a replace-or-append upsert; a rail save persists its draft).
114
+ * 1. a fresh id from the session's injected factory (`writes.newId`, EC-578 — UUIDs, so
115
+ * two sessions duplicating at once can never mint the same id);
116
+ * 2. ONE whole-document create — insert-if-absent (`writes.insert` →
117
+ * `documents.replace` `{ ifAbsent: true }`), never an overwrite. (It used to be a
118
+ * blank `create()` followed by a whole-document save — two writes.)
126
119
  *
127
- * The session store applies both optimistically, so the new row is visible
128
- * without a reload, and the persister mirrors them onto the operations facade
129
- * with the user's session — no new trust boundary. Callers gate on the
130
- * explicit `capabilities.duplicate` flag (the row-menu vocabulary / the header
131
- * gate) before dispatching — CMS collections only (owner call 2026-07-17;
132
- * every primitive rail declares `duplicate: false`).
120
+ * The session shows the new row at once (the persister's optimistic view). Callers
121
+ * gate on the explicit `capabilities.duplicate` flag (the row-menu vocabulary / the
122
+ * header gate) before dispatching — CMS collections only (owner call 2026-07-17; every
123
+ * primitive rail declares `duplicate: false` and writes records, so `undefined`).
133
124
  */
134
125
  export function duplicateFromSource(source, original) {
135
- // Snapshot the slug pool BEFORE create() inserts the blank record.
126
+ if (source.writes.kind !== 'document')
127
+ return undefined;
136
128
  const siblings = source.documents();
137
- const ref = source.create();
138
- source.save(deriveDuplicateDocument({ original, newId: ref.id, schema: source.schema, siblings }));
139
- return ref;
129
+ const id = source.writes.newId();
130
+ source.writes.insert(deriveDuplicateDocument({ original, newId: id, schema: source.schema, siblings }));
131
+ return { collection: source.collectionId, id };
140
132
  }
141
133
  //# sourceMappingURL=duplicate-document.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"duplicate-document.js","sourceRoot":"","sources":["../../../src/components/cms/duplicate-document.ts"],"names":[],"mappings":"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","sourcesContent":["import type { CmsDocument, CollectionDef, DocumentRef, FieldDef } from '@elytracms/core/cms-core'\nimport type { CollectionViewSource } from '@elytracms/studio/lib/collection-view/collection-view-source'\n\n/**\n * EC-442 — the duplicate-document derivation (pure core) + the source-composed\n * dispatch behind the list row `⋮` menu's and the detail-editor header's\n * **Duplicate** action.\n *\n * Semantics (ratified 2026-07-17):\n * - The duplicate copies the current DRAFT field values — `values` plus\n * `localized` across ALL locales — into a fresh document id (EC-580 deleted the\n * per-document `defaultLocale` this used to copy). It is created as a **draft, never auto-published** (\"live is\n * published-only\"): published-ness is the version pointer (EC-224), and\n * nothing here touches it.\n * - `alias` is deliberately NOT copied: an alias is a unique-within-collection\n * slot handle (EC-284) — code addresses THE document holding it, and a copied\n * alias would immediately surface as uniqueness drift. The duplicate is new\n * content, not the slot holder.\n * - **Slug strategy: uniquify, never clear.** Every slug field (a `text` field\n * named `slug` or opted in via `form.control: 'slug'` — the EC-315\n * convention) gets a deterministic `-copy` / `-copy-2` / … suffix walked past\n * every slug string the collection already holds (base values AND every\n * locale variant of every sibling). Rationale: a cleared slug lands the new\n * draft in an error-looking validation state, while a uniquified one reads as\n * what it is (\"about-copy\") and keeps the draft one review away from\n * publishable. A missing/duplicate slug stays publish-gating only — this\n * derivation never blocks a create.\n * - Composition (`blocks`) field values copy VERBATIM (deep clone): node ids\n * only need uniqueness within one composition value (`validateCompositionValue`\n * checks duplicates per value; the builder's fresh-id counter re-derives from\n * the open value via `maxNodeIdSeq`), so identical node ids across two\n * documents collide nowhere.\n * - References/relations copy as-is (the duplicate points at the same targets);\n * the EC-277 in-transaction reference derivation indexes the new document on\n * its first persisted write because it catches every writer.\n */\n\n/** The conventional slug field name (mirrors `field-control`'s slug auto-upgrade). */\nconst SLUG_FIELD_NAME = 'slug'\n/** The built-in control name that opts any `text` field into slug behaviour (EC-315). */\nconst SLUG_CONTROL = 'slug'\n\n/** The EC-315 slug-field convention: a `text` field named `slug`, or any `text`\n * field with `form.control: 'slug'` (same predicate as the studio slug control). */\nfunction isSlugField(field: FieldDef): boolean {\n return (\n field.type === 'text' && (field.form?.control === SLUG_CONTROL || field.name === SLUG_FIELD_NAME)\n )\n}\n\n/**\n * Every slug string the collection already holds for `fieldName` — the base\n * `values` slot AND every per-locale slot of every sibling (the original\n * included). A union pool across locales is deliberately conservative: a\n * candidate free in the pool can never collide with any sibling in any locale,\n * including through the EC-018 base-value fallback.\n */\nfunction slugPool(fieldName: string, siblings: readonly CmsDocument[]): Set<string> {\n const pool = new Set<string>()\n const add = (value: unknown) => {\n if (typeof value === 'string' && value.trim() !== '') pool.add(value.trim())\n }\n for (const doc of siblings) {\n add(doc.values?.[fieldName])\n for (const localeValues of Object.values(doc.localized ?? {})) add(localeValues[fieldName])\n }\n return pool\n}\n\n/**\n * The deterministic duplicate slug: `<slug>-copy`, then `<slug>-copy-2`,\n * `<slug>-copy-3`, … — the first candidate not in `taken`. Pure function of\n * `(slug, taken)`, so the SAME original string in the base slot and a locale\n * slot maps to the SAME uniquified string (the duplicate keeps the original's\n * internal base/locale relationship).\n */\nexport function uniquifySlug(slug: string, taken: ReadonlySet<string>): string {\n const base = `${slug}-copy`\n if (!taken.has(base)) return base\n let n = 2\n while (taken.has(`${base}-${n}`)) n += 1\n return `${base}-${n}`\n}\n\n/** Uniquify one stored slug slot in place; empty/non-string slots stay untouched\n * (a missing slug remains a publish-gating issue, never a create blocker). */\nfunction uniquifySlot(\n values: Record<string, unknown> | undefined,\n fieldName: string,\n pool: ReadonlySet<string>,\n): void {\n if (!values) return\n const raw = values[fieldName]\n if (typeof raw !== 'string' || raw.trim() === '') return\n values[fieldName] = uniquifySlug(raw.trim(), pool)\n}\n\nexport interface DeriveDuplicateInput {\n /** The document being duplicated — the CURRENT draft values (list row / editor buffer). */\n original: CmsDocument\n /** The freshly minted id for the duplicate (source-owned, via `source.create()`). */\n newId: string\n /** The collection schema — identifies the slug fields to uniquify. */\n schema: CollectionDef\n /** The collection's current rows (the original included) — the slug pool. */\n siblings: readonly CmsDocument[]\n}\n\n/**\n * Derive the duplicate document (pure): deep-copied `values` + `localized` (all\n * locales) under the fresh id, `alias` dropped, every slug field's stored slots\n * uniquified against the collection's slug pool. (EC-580: there is no per-document\n * `defaultLocale` to copy — the project's config is the only default.) See the\n * module doc for the ratified semantics each rule pins.\n */\nexport function deriveDuplicateDocument({\n original,\n newId,\n schema,\n siblings,\n}: DeriveDuplicateInput): CmsDocument {\n const duplicate: CmsDocument = {\n collection: original.collection,\n id: newId,\n // Deep clones: composition trees, rich text, nested objects/arrays must be\n // independent of the original (editing one must never mutate the other).\n values: structuredClone(original.values ?? {}),\n ...(original.localized !== undefined ? { localized: structuredClone(original.localized) } : {}),\n // `alias` deliberately dropped (unique slot handle — see module doc).\n }\n for (const field of schema.fields) {\n if (!isSlugField(field)) continue\n const pool = slugPool(field.name, siblings)\n uniquifySlot(duplicate.values, field.name, pool)\n for (const localeValues of Object.values(duplicate.localized ?? {})) {\n uniquifySlot(localeValues, field.name, pool)\n }\n }\n return duplicate\n}\n\n/**\n * Duplicate `original` through a {@link CollectionViewSource} — the ONE dispatch\n * both surfaces (list row menu, detail-editor header) share. Composes the seam's\n * EXISTING write paths, never a parallel one (AD-17):\n *\n * 1. `source.create()` mints the fresh id + inserts the rail's blank record\n * (the same collision-free mint the New button uses);\n * 2. `source.save(derived)` lands the copied values under that id (the CMS\n * save is a replace-or-append upsert; a rail save persists its draft).\n *\n * The session store applies both optimistically, so the new row is visible\n * without a reload, and the persister mirrors them onto the operations facade\n * with the user's session — no new trust boundary. Callers gate on the\n * explicit `capabilities.duplicate` flag (the row-menu vocabulary / the header\n * gate) before dispatching — CMS collections only (owner call 2026-07-17;\n * every primitive rail declares `duplicate: false`).\n */\nexport function duplicateFromSource(\n source: CollectionViewSource,\n original: CmsDocument,\n): DocumentRef {\n // Snapshot the slug pool BEFORE create() inserts the blank record.\n const siblings = source.documents()\n const ref = source.create()\n source.save(deriveDuplicateDocument({ original, newId: ref.id, schema: source.schema, siblings }))\n return ref\n}\n"]}
1
+ {"version":3,"file":"duplicate-document.js","sourceRoot":"","sources":["../../../src/components/cms/duplicate-document.ts"],"names":[],"mappings":"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","sourcesContent":["import { slugFieldOf } from '@elytracms/core/cms-core'\nimport type { CmsDocument, CollectionDef, DocumentRef } from '@elytracms/core/cms-core'\nimport type { CollectionViewSource } from '@elytracms/studio/lib/collection-view/collection-view-source'\n\n/**\n * EC-442 — the duplicate-document derivation (pure core) + the source-composed\n * dispatch behind the list row `⋮` menu's and the detail-editor header's\n * **Duplicate** action.\n *\n * Semantics (ratified 2026-07-17):\n * - The duplicate copies the current DRAFT field values — `values` plus\n * `localized` across ALL locales — into a fresh document id (EC-580 deleted the\n * per-document `defaultLocale` this used to copy). It is created as a **draft, never auto-published** (\"live is\n * published-only\"): published-ness is the version pointer (EC-224), and\n * nothing here touches it.\n * - `alias` is deliberately NOT copied: an alias is a unique-within-collection\n * slot handle (EC-284) — code addresses THE document holding it, and a copied\n * alias would immediately surface as uniqueness drift. The duplicate is new\n * content, not the slot holder.\n * - **Slug strategy: uniquify, never clear.** Every slug field (a `text` field\n * named `slug` or opted in via `form.control: 'slug'` — the EC-315\n * convention) gets a deterministic `-copy` / `-copy-2` / … suffix walked past\n * every slug string the collection already holds (base values AND every\n * locale variant of every sibling). Rationale: a cleared slug lands the new\n * draft in an error-looking validation state, while a uniquified one reads as\n * what it is (\"about-copy\") and keeps the draft one review away from\n * publishable. A missing/duplicate slug stays publish-gating only — this\n * derivation never blocks a create.\n * - Composition (`blocks`) field values copy VERBATIM (deep clone): node ids\n * only need uniqueness within one composition value (`validateCompositionValue`\n * checks duplicates per value; the builder mints fresh ids from the injected\n * UUID factory, EC-578), so identical node ids across two documents collide\n * nowhere.\n * - References/relations copy as-is (the duplicate points at the same targets);\n * the EC-277 in-transaction reference derivation indexes the new document on\n * its first persisted write because it catches every writer.\n */\n\n\n/**\n * Every slug string the collection already holds for `fieldName` — the base\n * `values` slot AND every per-locale slot of every sibling (the original\n * included). A union pool across locales is deliberately conservative: a\n * candidate free in the pool can never collide with any sibling in any locale,\n * including through the EC-018 base-value fallback.\n */\nfunction slugPool(fieldName: string, siblings: readonly CmsDocument[]): Set<string> {\n const pool = new Set<string>()\n const add = (value: unknown) => {\n if (typeof value === 'string' && value.trim() !== '') pool.add(value.trim())\n }\n for (const doc of siblings) {\n add(doc.values?.[fieldName])\n for (const localeValues of Object.values(doc.localized ?? {})) add(localeValues[fieldName])\n }\n return pool\n}\n\n/**\n * The deterministic duplicate slug: `<slug>-copy`, then `<slug>-copy-2`,\n * `<slug>-copy-3`, … — the first candidate not in `taken`. Pure function of\n * `(slug, taken)`, so the SAME original string in the base slot and a locale\n * slot maps to the SAME uniquified string (the duplicate keeps the original's\n * internal base/locale relationship).\n */\nexport function uniquifySlug(slug: string, taken: ReadonlySet<string>): string {\n const base = `${slug}-copy`\n if (!taken.has(base)) return base\n let n = 2\n while (taken.has(`${base}-${n}`)) n += 1\n return `${base}-${n}`\n}\n\n/** Uniquify one stored slug slot in place; empty/non-string slots stay untouched\n * (a missing slug remains a publish-gating issue, never a create blocker). */\nfunction uniquifySlot(\n values: Record<string, unknown> | undefined,\n fieldName: string,\n pool: ReadonlySet<string>,\n): void {\n if (!values) return\n const raw = values[fieldName]\n if (typeof raw !== 'string' || raw.trim() === '') return\n values[fieldName] = uniquifySlug(raw.trim(), pool)\n}\n\nexport interface DeriveDuplicateInput {\n /** The document being duplicated — the CURRENT draft values (list row / editor buffer). */\n original: CmsDocument\n /** The freshly minted id for the duplicate (source-owned, via `source.create()`). */\n newId: string\n /** The collection schema — identifies the slug fields to uniquify. */\n schema: CollectionDef\n /** The collection's current rows (the original included) — the slug pool. */\n siblings: readonly CmsDocument[]\n}\n\n/**\n * Derive the duplicate document (pure): deep-copied `values` + `localized` (all\n * locales) under the fresh id, `alias` dropped, every slug field's stored slots\n * uniquified against the collection's slug pool. (EC-580: there is no per-document\n * `defaultLocale` to copy — the project's config is the only default.) See the\n * module doc for the ratified semantics each rule pins.\n */\nexport function deriveDuplicateDocument({\n original,\n newId,\n schema,\n siblings,\n}: DeriveDuplicateInput): CmsDocument {\n const duplicate: CmsDocument = {\n collection: original.collection,\n id: newId,\n // Deep clones: composition trees, rich text, nested objects/arrays must be\n // independent of the original (editing one must never mutate the other).\n values: structuredClone(original.values ?? {}),\n ...(original.localized !== undefined ? { localized: structuredClone(original.localized) } : {}),\n // `alias` deliberately dropped (unique slot handle — see module doc).\n }\n // EC-590: the collection's SLUG ROLE (`slugField`, default a text field named `slug`)\n // — the one field routing reads, so the one a duplicate must make unique.\n const slugField = slugFieldOf(schema)?.name\n if (slugField !== undefined) {\n const pool = slugPool(slugField, siblings)\n uniquifySlot(duplicate.values, slugField, pool)\n for (const localeValues of Object.values(duplicate.localized ?? {})) {\n uniquifySlot(localeValues, slugField, pool)\n }\n }\n return duplicate\n}\n\n/**\n * Duplicate `original` through a {@link CollectionViewSource} — the ONE dispatch\n * both surfaces (list row menu, detail-editor header) share (EC-595 A2):\n *\n * 1. a fresh id from the session's injected factory (`writes.newId`, EC-578 — UUIDs, so\n * two sessions duplicating at once can never mint the same id);\n * 2. ONE whole-document create — insert-if-absent (`writes.insert` →\n * `documents.replace` `{ ifAbsent: true }`), never an overwrite. (It used to be a\n * blank `create()` followed by a whole-document save — two writes.)\n *\n * The session shows the new row at once (the persister's optimistic view). Callers\n * gate on the explicit `capabilities.duplicate` flag (the row-menu vocabulary / the\n * header gate) before dispatching — CMS collections only (owner call 2026-07-17; every\n * primitive rail declares `duplicate: false` and writes records, so `undefined`).\n */\nexport function duplicateFromSource(\n source: CollectionViewSource,\n original: CmsDocument,\n): DocumentRef | undefined {\n if (source.writes.kind !== 'document') return undefined\n const siblings = source.documents()\n const id = source.writes.newId()\n source.writes.insert(deriveDuplicateDocument({ original, newId: id, schema: source.schema, siblings }))\n return { collection: source.collectionId, id }\n}\n"]}
@@ -59,9 +59,12 @@ export declare function isPathWithin(itemPath: string, focusPath: string): boole
59
59
  /**
60
60
  * Walk a parsed field path down the schema and return the DEEPEST resolvable
61
61
  * field def: numeric tokens step into an array item (same def), name tokens
62
- * descend into an `object` field's subfields. An unknown token stops the walk
63
- * at the last resolved def (label degradation mirrors anchor degradation);
64
- * an unknown top-level field is `null`. Rich-text node paths therefore resolve
65
- * to the rich-text field itself.
62
+ * descend into a sub-field — of an `object` (one item or a repeater's) or of an
63
+ * `asset` placement (EC-582 / EC-572 #1: an asset sub-field issue now anchors at the
64
+ * sub-field's input, not the asset field). The step is cms-core's ONE definition
65
+ * descent (`descendFieldDefinition`, the value-position grammar's continuation) — no
66
+ * second schema walk here. An unknown token stops the walk at the last resolved def
67
+ * (label degradation mirrors anchor degradation); an unknown top-level field is
68
+ * `null`. Rich-text node paths therefore resolve to the rich-text field itself.
66
69
  */
67
70
  export declare function resolveFieldDefAtPath(collection: CollectionDef, field: string, rest: readonly string[]): FieldDef | null;
@@ -1,4 +1,5 @@
1
1
  import { createContext, useContext } from 'react';
2
+ import { descendFieldDefinition } from '@elytracms/core/cms-core';
2
3
  import { FIELD_PATH_ATTR } from '@elytracms/studio/ui';
3
4
  /**
4
5
  * EC-443 — the pure core of path-aware jump-to-field.
@@ -67,10 +68,13 @@ export function isPathWithin(itemPath, focusPath) {
67
68
  /**
68
69
  * Walk a parsed field path down the schema and return the DEEPEST resolvable
69
70
  * field def: numeric tokens step into an array item (same def), name tokens
70
- * descend into an `object` field's subfields. An unknown token stops the walk
71
- * at the last resolved def (label degradation mirrors anchor degradation);
72
- * an unknown top-level field is `null`. Rich-text node paths therefore resolve
73
- * to the rich-text field itself.
71
+ * descend into a sub-field — of an `object` (one item or a repeater's) or of an
72
+ * `asset` placement (EC-582 / EC-572 #1: an asset sub-field issue now anchors at the
73
+ * sub-field's input, not the asset field). The step is cms-core's ONE definition
74
+ * descent (`descendFieldDefinition`, the value-position grammar's continuation) — no
75
+ * second schema walk here. An unknown token stops the walk at the last resolved def
76
+ * (label degradation mirrors anchor degradation); an unknown top-level field is
77
+ * `null`. Rich-text node paths therefore resolve to the rich-text field itself.
74
78
  */
75
79
  export function resolveFieldDefAtPath(collection, field, rest) {
76
80
  let current = collection.fields.find((f) => f.name === field);
@@ -79,9 +83,7 @@ export function resolveFieldDefAtPath(collection, field, rest) {
79
83
  for (const token of rest) {
80
84
  if (/^\d+$/.test(token))
81
85
  continue;
82
- if (current.type !== 'object')
83
- break;
84
- const sub = current.fields.find((f) => f.name === token);
86
+ const sub = descendFieldDefinition(current, [token]);
85
87
  if (sub === undefined)
86
88
  break;
87
89
  current = sub;
@@ -1 +1 @@
1
- {"version":3,"file":"field-anchors.js","sourceRoot":"","sources":["../../../src/components/cms/field-anchors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,OAAO,CAAA;AAEjD,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AAEtD;;;;;;;;;;;;;;;;;;GAkBG;AAEH,iFAAiF;AAEjF,iFAAiF;AACjF,MAAM,CAAC,MAAM,gBAAgB,GAAG,aAAa,CAAgB,IAAI,CAAC,CAAA;AAClE,MAAM,CAAC,MAAM,YAAY,GAAG,GAAkB,EAAE,CAAC,UAAU,CAAC,gBAAgB,CAAC,CAAA;AAY7E,MAAM,CAAC,MAAM,wBAAwB,GAAG,aAAa,CAA2B,IAAI,CAAC,CAAA;AACrF,MAAM,CAAC,MAAM,oBAAoB,GAAG,GAA6B,EAAE,CACjE,UAAU,CAAC,wBAAwB,CAAC,CAAA;AAEtC,iFAAiF;AAEjF,gDAAgD;AAChD,MAAM,UAAU,mBAAmB,CAAC,IAAY;IAC9C,OAAO,IAAI,eAAe,KAAK,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,CAAA;AAC9D,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAa,EAAE,IAAuB;IACrE,MAAM,GAAG,GAAa,EAAE,CAAA;IACxB,KAAK,IAAI,KAAK,GAAG,IAAI,CAAC,MAAM,EAAE,KAAK,IAAI,CAAC,EAAE,KAAK,EAAE,EAAE,CAAC;QAClD,GAAG,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAA;IACtD,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,qBAAqB,CACnC,UAA6B,EAC7B,MAAiC;IAEjC,KAAK,MAAM,SAAS,IAAI,UAAU;QAAE,IAAI,MAAM,CAAC,SAAS,CAAC;YAAE,OAAO,SAAS,CAAA;IAC3E,OAAO,IAAI,CAAA;AACb,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,QAAgB,EAAE,SAAiB;IAC9D,OAAO,SAAS,KAAK,QAAQ,IAAI,SAAS,CAAC,UAAU,CAAC,GAAG,QAAQ,GAAG,CAAC,CAAA;AACvE,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,qBAAqB,CACnC,UAAyB,EACzB,KAAa,EACb,IAAuB;IAEvB,IAAI,OAAO,GAAyB,UAAU,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,KAAK,CAAC,CAAA;IACnF,IAAI,OAAO,KAAK,SAAS;QAAE,OAAO,IAAI,CAAA;IACtC,KAAK,MAAM,KAAK,IAAI,IAAI,EAAE,CAAC;QACzB,IAAI,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC;YAAE,SAAQ;QACjC,IAAI,OAAO,CAAC,IAAI,KAAK,QAAQ;YAAE,MAAK;QACpC,MAAM,GAAG,GAAyB,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,KAAK,CAAC,CAAA;QAC9E,IAAI,GAAG,KAAK,SAAS;YAAE,MAAK;QAC5B,OAAO,GAAG,GAAG,CAAA;IACf,CAAC;IACD,OAAO,OAAO,CAAA;AAChB,CAAC","sourcesContent":["import { createContext, useContext } from 'react'\nimport type { CollectionDef, FieldDef } from '@elytracms/core/cms-core'\nimport { FIELD_PATH_ATTR } from '@elytracms/studio/ui'\n\n/**\n * EC-443 — the pure core of path-aware jump-to-field.\n *\n * The anchor convention itself (`data-ec-field-path=\"<field>(/<token>)*\"`) is\n * defined ONCE, on {@link FIELD_PATH_ATTR} in `ui/field.tsx` — read its doc\n * comment before touching anything here. This module carries the shared\n * plumbing around it:\n *\n * - {@link FieldPathContext}: the current anchor-path prefix, threaded through\n * the recursive object/repeater controls so each nested row knows its own\n * full path without prop-drilling. `null` outside a stamped surface (e.g.\n * the canvas prop inspector), where no anchors are stamped.\n * - {@link FieldFocusRequestContext}: the active focus request (set by\n * `useFieldFocus`), which drives BOTH collapsed-array auto-expansion en\n * route and the ring highlight on the exact nested control.\n * - The pure focus-plan decisions (`anchorCandidates`,\n * `deepestExistingAnchor`, `isPathWithin`, `resolveFieldDefAtPath`) — no\n * DOM, directly pinned in `use-field-focus.test.ts`.\n */\n\n// --- Contexts -----------------------------------------------------------------\n\n/** The anchor-path prefix for the controls below this point (see module doc). */\nexport const FieldPathContext = createContext<string | null>(null)\nexport const useFieldPath = (): string | null => useContext(FieldPathContext)\n\n/**\n * The in-flight jump-to-field request. `path` is a full anchor path\n * (`links/3/badge/text`); `nonce` distinguishes back-to-back jumps to the same\n * path (a re-click must re-expand a manually collapsed item). Cleared when the\n * highlight window ends.\n */\nexport interface FieldFocusRequest {\n path: string\n nonce: number\n}\nexport const FieldFocusRequestContext = createContext<FieldFocusRequest | null>(null)\nexport const useFieldFocusRequest = (): FieldFocusRequest | null =>\n useContext(FieldFocusRequestContext)\n\n// --- Pure focus-plan decisions ------------------------------------------------\n\n/** CSS selector for one stamped anchor path. */\nexport function fieldAnchorSelector(path: string): string {\n return `[${FIELD_PATH_ATTR}=\"${path.replace(/\"/g, '\\\\\"')}\"]`\n}\n\n/**\n * The anchor lookup order for a parsed field path: every prefix, deepest\n * first, down to the top-level field itself — the graceful-degradation ladder\n * (`links/3/badge/text` → `links/3/badge` → `links/3` → `links`). The\n * top-level field is additionally always reachable via its `#f-<name>`\n * control id, which is the ultimate floor.\n */\nexport function anchorCandidates(field: string, rest: readonly string[]): string[] {\n const out: string[] = []\n for (let depth = rest.length; depth >= 0; depth--) {\n out.push([field, ...rest.slice(0, depth)].join('/'))\n }\n return out\n}\n\n/**\n * The deepest candidate that exists right now (`exists` = a DOM probe in\n * production, a fake in tests), or `null` when none does. Combined with\n * {@link anchorCandidates} this IS the degradation decision: deepest existing\n * anchor wins, the top-level field is the floor, never a no-op.\n */\nexport function deepestExistingAnchor(\n candidates: readonly string[],\n exists: (path: string) => boolean,\n): string | null {\n for (const candidate of candidates) if (exists(candidate)) return candidate\n return null\n}\n\n/**\n * Is `focusPath` at or below `itemPath`? Token-prefix semantics (`links/3`\n * contains `links/3/badge/text` but NOT `links/31`) — the expansion decision a\n * collapsed array item uses to auto-open on the way to a nested target.\n */\nexport function isPathWithin(itemPath: string, focusPath: string): boolean {\n return focusPath === itemPath || focusPath.startsWith(`${itemPath}/`)\n}\n\n/**\n * Walk a parsed field path down the schema and return the DEEPEST resolvable\n * field def: numeric tokens step into an array item (same def), name tokens\n * descend into an `object` field's subfields. An unknown token stops the walk\n * at the last resolved def (label degradation mirrors anchor degradation);\n * an unknown top-level field is `null`. Rich-text node paths therefore resolve\n * to the rich-text field itself.\n */\nexport function resolveFieldDefAtPath(\n collection: CollectionDef,\n field: string,\n rest: readonly string[],\n): FieldDef | null {\n let current: FieldDef | undefined = collection.fields.find((f) => f.name === field)\n if (current === undefined) return null\n for (const token of rest) {\n if (/^\\d+$/.test(token)) continue\n if (current.type !== 'object') break\n const sub: FieldDef | undefined = current.fields.find((f) => f.name === token)\n if (sub === undefined) break\n current = sub\n }\n return current\n}\n"]}
1
+ {"version":3,"file":"field-anchors.js","sourceRoot":"","sources":["../../../src/components/cms/field-anchors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,OAAO,CAAA;AACjD,OAAO,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAA;AAEjE,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AAEtD;;;;;;;;;;;;;;;;;;GAkBG;AAEH,iFAAiF;AAEjF,iFAAiF;AACjF,MAAM,CAAC,MAAM,gBAAgB,GAAG,aAAa,CAAgB,IAAI,CAAC,CAAA;AAClE,MAAM,CAAC,MAAM,YAAY,GAAG,GAAkB,EAAE,CAAC,UAAU,CAAC,gBAAgB,CAAC,CAAA;AAY7E,MAAM,CAAC,MAAM,wBAAwB,GAAG,aAAa,CAA2B,IAAI,CAAC,CAAA;AACrF,MAAM,CAAC,MAAM,oBAAoB,GAAG,GAA6B,EAAE,CACjE,UAAU,CAAC,wBAAwB,CAAC,CAAA;AAEtC,iFAAiF;AAEjF,gDAAgD;AAChD,MAAM,UAAU,mBAAmB,CAAC,IAAY;IAC9C,OAAO,IAAI,eAAe,KAAK,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,CAAA;AAC9D,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAa,EAAE,IAAuB;IACrE,MAAM,GAAG,GAAa,EAAE,CAAA;IACxB,KAAK,IAAI,KAAK,GAAG,IAAI,CAAC,MAAM,EAAE,KAAK,IAAI,CAAC,EAAE,KAAK,EAAE,EAAE,CAAC;QAClD,GAAG,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAA;IACtD,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,qBAAqB,CACnC,UAA6B,EAC7B,MAAiC;IAEjC,KAAK,MAAM,SAAS,IAAI,UAAU;QAAE,IAAI,MAAM,CAAC,SAAS,CAAC;YAAE,OAAO,SAAS,CAAA;IAC3E,OAAO,IAAI,CAAA;AACb,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,QAAgB,EAAE,SAAiB;IAC9D,OAAO,SAAS,KAAK,QAAQ,IAAI,SAAS,CAAC,UAAU,CAAC,GAAG,QAAQ,GAAG,CAAC,CAAA;AACvE,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,qBAAqB,CACnC,UAAyB,EACzB,KAAa,EACb,IAAuB;IAEvB,IAAI,OAAO,GAAyB,UAAU,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,KAAK,CAAC,CAAA;IACnF,IAAI,OAAO,KAAK,SAAS;QAAE,OAAO,IAAI,CAAA;IACtC,KAAK,MAAM,KAAK,IAAI,IAAI,EAAE,CAAC;QACzB,IAAI,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC;YAAE,SAAQ;QACjC,MAAM,GAAG,GAAyB,sBAAsB,CAAC,OAAO,EAAE,CAAC,KAAK,CAAC,CAAC,CAAA;QAC1E,IAAI,GAAG,KAAK,SAAS;YAAE,MAAK;QAC5B,OAAO,GAAG,GAAG,CAAA;IACf,CAAC;IACD,OAAO,OAAO,CAAA;AAChB,CAAC","sourcesContent":["import { createContext, useContext } from 'react'\nimport { descendFieldDefinition } from '@elytracms/core/cms-core'\nimport type { CollectionDef, FieldDef } from '@elytracms/core/cms-core'\nimport { FIELD_PATH_ATTR } from '@elytracms/studio/ui'\n\n/**\n * EC-443 — the pure core of path-aware jump-to-field.\n *\n * The anchor convention itself (`data-ec-field-path=\"<field>(/<token>)*\"`) is\n * defined ONCE, on {@link FIELD_PATH_ATTR} in `ui/field.tsx` — read its doc\n * comment before touching anything here. This module carries the shared\n * plumbing around it:\n *\n * - {@link FieldPathContext}: the current anchor-path prefix, threaded through\n * the recursive object/repeater controls so each nested row knows its own\n * full path without prop-drilling. `null` outside a stamped surface (e.g.\n * the canvas prop inspector), where no anchors are stamped.\n * - {@link FieldFocusRequestContext}: the active focus request (set by\n * `useFieldFocus`), which drives BOTH collapsed-array auto-expansion en\n * route and the ring highlight on the exact nested control.\n * - The pure focus-plan decisions (`anchorCandidates`,\n * `deepestExistingAnchor`, `isPathWithin`, `resolveFieldDefAtPath`) — no\n * DOM, directly pinned in `use-field-focus.test.ts`.\n */\n\n// --- Contexts -----------------------------------------------------------------\n\n/** The anchor-path prefix for the controls below this point (see module doc). */\nexport const FieldPathContext = createContext<string | null>(null)\nexport const useFieldPath = (): string | null => useContext(FieldPathContext)\n\n/**\n * The in-flight jump-to-field request. `path` is a full anchor path\n * (`links/3/badge/text`); `nonce` distinguishes back-to-back jumps to the same\n * path (a re-click must re-expand a manually collapsed item). Cleared when the\n * highlight window ends.\n */\nexport interface FieldFocusRequest {\n path: string\n nonce: number\n}\nexport const FieldFocusRequestContext = createContext<FieldFocusRequest | null>(null)\nexport const useFieldFocusRequest = (): FieldFocusRequest | null =>\n useContext(FieldFocusRequestContext)\n\n// --- Pure focus-plan decisions ------------------------------------------------\n\n/** CSS selector for one stamped anchor path. */\nexport function fieldAnchorSelector(path: string): string {\n return `[${FIELD_PATH_ATTR}=\"${path.replace(/\"/g, '\\\\\"')}\"]`\n}\n\n/**\n * The anchor lookup order for a parsed field path: every prefix, deepest\n * first, down to the top-level field itself — the graceful-degradation ladder\n * (`links/3/badge/text` → `links/3/badge` → `links/3` → `links`). The\n * top-level field is additionally always reachable via its `#f-<name>`\n * control id, which is the ultimate floor.\n */\nexport function anchorCandidates(field: string, rest: readonly string[]): string[] {\n const out: string[] = []\n for (let depth = rest.length; depth >= 0; depth--) {\n out.push([field, ...rest.slice(0, depth)].join('/'))\n }\n return out\n}\n\n/**\n * The deepest candidate that exists right now (`exists` = a DOM probe in\n * production, a fake in tests), or `null` when none does. Combined with\n * {@link anchorCandidates} this IS the degradation decision: deepest existing\n * anchor wins, the top-level field is the floor, never a no-op.\n */\nexport function deepestExistingAnchor(\n candidates: readonly string[],\n exists: (path: string) => boolean,\n): string | null {\n for (const candidate of candidates) if (exists(candidate)) return candidate\n return null\n}\n\n/**\n * Is `focusPath` at or below `itemPath`? Token-prefix semantics (`links/3`\n * contains `links/3/badge/text` but NOT `links/31`) — the expansion decision a\n * collapsed array item uses to auto-open on the way to a nested target.\n */\nexport function isPathWithin(itemPath: string, focusPath: string): boolean {\n return focusPath === itemPath || focusPath.startsWith(`${itemPath}/`)\n}\n\n/**\n * Walk a parsed field path down the schema and return the DEEPEST resolvable\n * field def: numeric tokens step into an array item (same def), name tokens\n * descend into a sub-field — of an `object` (one item or a repeater's) or of an\n * `asset` placement (EC-582 / EC-572 #1: an asset sub-field issue now anchors at the\n * sub-field's input, not the asset field). The step is cms-core's ONE definition\n * descent (`descendFieldDefinition`, the value-position grammar's continuation) — no\n * second schema walk here. An unknown token stops the walk at the last resolved def\n * (label degradation mirrors anchor degradation); an unknown top-level field is\n * `null`. Rich-text node paths therefore resolve to the rich-text field itself.\n */\nexport function resolveFieldDefAtPath(\n collection: CollectionDef,\n field: string,\n rest: readonly string[],\n): FieldDef | null {\n let current: FieldDef | undefined = collection.fields.find((f) => f.name === field)\n if (current === undefined) return null\n for (const token of rest) {\n if (/^\\d+$/.test(token)) continue\n const sub: FieldDef | undefined = descendFieldDefinition(current, [token])\n if (sub === undefined) break\n current = sub\n }\n return current\n}\n"]}
@@ -0,0 +1,47 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { ValuePath } from '@elytracms/core/cms-core';
3
+ import type { PresenceEntry } from '@elytracms/core/persistence';
4
+ import type { DocumentConflict } from '@elytracms/studio/lib/operations';
5
+ /**
6
+ * EC-595 A2 — the field's collaboration chrome: the commit boundary (focus / blur /
7
+ * Enter → the commit policy, presence), the per-field conflict ("changed by X — take
8
+ * theirs / keep mine"), and the thin presence note ("X is in this field").
9
+ */
10
+ /**
11
+ * The commit boundary around one field's control: focus entering it, focus LEAVING it
12
+ * (to anywhere outside — a picker's portal inside the control counts as inside only
13
+ * while its DOM is), and Enter in a single-line input are the triggers the field's
14
+ * commit policy reads (`commit-policy.ts`). A wrapper, so every control — built-in or a
15
+ * project renderer — is covered without knowing about commits.
16
+ */
17
+ export declare function FieldCommitBoundary({ onFocusField, onBlurField, onEnter, children, }: {
18
+ onFocusField: () => void;
19
+ onBlurField: () => void;
20
+ onEnter: () => void;
21
+ children: ReactNode;
22
+ }): import("react").JSX.Element;
23
+ /** Whether a position belongs to a field row (the field, any of its locale buckets / sub-positions). */
24
+ export declare function pathInField(path: ValuePath, field: string, locale: string | undefined): boolean;
25
+ /** The display name of a conflict's actor: a present colleague's name, "you (another tab)", or someone. */
26
+ export declare function conflictActorName(conflict: DocumentConflict, presence: readonly PresenceEntry[], selfActor: string | undefined): string;
27
+ /** A short, plain rendering of "theirs" for the notice (scalars verbatim, structures summarised). */
28
+ export declare function conflictValuePreview(value: unknown): string;
29
+ /**
30
+ * The per-field conflict (EC-595 binding point 5): a path this session changed was
31
+ * changed by someone else first, and the store refused it — the rest of the edit applied.
32
+ * The field keeps showing MINE until the user decides: take theirs (adopt the server
33
+ * value) or keep mine (re-send it at the current revision — a visible, second write).
34
+ * Never a whole-document dialog.
35
+ */
36
+ export declare function FieldConflictNotice({ conflict, actorName, onTakeTheirs, onKeepMine, }: {
37
+ conflict: DocumentConflict;
38
+ actorName: string;
39
+ onTakeTheirs: () => void;
40
+ onKeepMine: () => void;
41
+ }): import("react").JSX.Element;
42
+ /** The OTHER sessions whose focused field is this one. */
43
+ export declare function presentInField(presence: readonly PresenceEntry[], path: ValuePath): PresenceEntry[];
44
+ /** "X is in this field" — the signal that keeps two editors out of one field (thin presence). */
45
+ export declare function FieldPresenceNote({ entries }: {
46
+ entries: readonly PresenceEntry[];
47
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,87 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { AlertTriangle, Users } from 'lucide-react';
3
+ import { valuePathsOverlap } from '@elytracms/core/cms-core';
4
+ import { Button, Row, Stack, Text } from '@elytracms/studio/ui';
5
+ import { translate } from '@elytracms/studio/lib/i18n';
6
+ /**
7
+ * EC-595 A2 — the field's collaboration chrome: the commit boundary (focus / blur /
8
+ * Enter → the commit policy, presence), the per-field conflict ("changed by X — take
9
+ * theirs / keep mine"), and the thin presence note ("X is in this field").
10
+ */
11
+ /**
12
+ * The commit boundary around one field's control: focus entering it, focus LEAVING it
13
+ * (to anywhere outside — a picker's portal inside the control counts as inside only
14
+ * while its DOM is), and Enter in a single-line input are the triggers the field's
15
+ * commit policy reads (`commit-policy.ts`). A wrapper, so every control — built-in or a
16
+ * project renderer — is covered without knowing about commits.
17
+ */
18
+ export function FieldCommitBoundary({ onFocusField, onBlurField, onEnter, children, }) {
19
+ return (_jsx("div", { "data-commit-boundary": "", onFocus: (event) => {
20
+ if (!event.currentTarget.contains(event.relatedTarget))
21
+ onFocusField();
22
+ }, onBlur: (event) => {
23
+ if (!event.currentTarget.contains(event.relatedTarget))
24
+ onBlurField();
25
+ }, onKeyDown: (event) => {
26
+ if (event.key === 'Enter' && event.target instanceof HTMLInputElement)
27
+ onEnter();
28
+ }, children: children }));
29
+ }
30
+ /** Whether a position belongs to a field row (the field, any of its locale buckets / sub-positions). */
31
+ export function pathInField(path, field, locale) {
32
+ const head = path[0];
33
+ if (!head || head.kind !== 'field' || head.name !== field)
34
+ return false;
35
+ const bucket = path[1];
36
+ if (bucket?.kind === 'locale' && locale !== undefined)
37
+ return bucket.locale === locale;
38
+ return true;
39
+ }
40
+ /** The display name of a conflict's actor: a present colleague's name, "you (another tab)", or someone. */
41
+ export function conflictActorName(conflict, presence, selfActor) {
42
+ if (conflict.actor !== undefined) {
43
+ const named = presence.find((entry) => entry.actor === conflict.actor && entry.actorName);
44
+ if (named?.actorName)
45
+ return named.actorName;
46
+ if (selfActor !== undefined && conflict.actor === selfActor)
47
+ return translate('cms.conflict.youInAnotherTab');
48
+ }
49
+ return translate('cms.conflict.someoneElse');
50
+ }
51
+ /** A short, plain rendering of "theirs" for the notice (scalars verbatim, structures summarised). */
52
+ export function conflictValuePreview(value) {
53
+ if (value === undefined || value === null || value === '')
54
+ return translate('cms.conflict.empty');
55
+ if (typeof value === 'string')
56
+ return value.length > 80 ? `${value.slice(0, 79)}…` : value;
57
+ if (typeof value === 'number' || typeof value === 'boolean')
58
+ return String(value);
59
+ return translate('cms.conflict.structured');
60
+ }
61
+ /**
62
+ * The per-field conflict (EC-595 binding point 5): a path this session changed was
63
+ * changed by someone else first, and the store refused it — the rest of the edit applied.
64
+ * The field keeps showing MINE until the user decides: take theirs (adopt the server
65
+ * value) or keep mine (re-send it at the current revision — a visible, second write).
66
+ * Never a whole-document dialog.
67
+ */
68
+ export function FieldConflictNotice({ conflict, actorName, onTakeTheirs, onKeepMine, }) {
69
+ const gone = conflict.reason === 'invalid' || (conflict.theirs === undefined && conflict.patch.op !== 'unset');
70
+ return (_jsxs(Stack, { gap: "1", className: "rounded-md border border-warning/40 bg-warning/10 p-2", "data-testid": "field-conflict", role: "alert", children: [_jsxs(Row, { gap: "2", align: "start", children: [_jsx(AlertTriangle, { "aria-hidden": true, className: "mt-0.5 size-3.5 shrink-0 text-warning" }), _jsx(Text, { size: "xs", children: gone
71
+ ? translate('cms.conflict.removedBy', { name: actorName })
72
+ : translate('cms.conflict.changedBy', { name: actorName }) })] }), gone ? null : (_jsx(Text, { size: "xs", tone: "muted", className: "pl-5", "data-testid": "field-conflict-theirs", children: translate('cms.conflict.theirs', { value: conflictValuePreview(conflict.theirs) }) })), _jsxs(Row, { gap: "2", className: "pl-5", children: [_jsx(Button, { size: "sm", variant: "outline", onClick: onTakeTheirs, "data-testid": "field-conflict-theirs-button", children: translate('cms.conflict.takeTheirs') }), _jsx(Button, { size: "sm", variant: "ghost", onClick: onKeepMine, "data-testid": "field-conflict-mine-button", children: translate('cms.conflict.keepMine') })] })] }));
73
+ }
74
+ /** The OTHER sessions whose focused field is this one. */
75
+ export function presentInField(presence, path) {
76
+ return presence.filter((entry) => entry.path !== undefined && valuePathsOverlap(entry.path, path));
77
+ }
78
+ /** "X is in this field" — the signal that keeps two editors out of one field (thin presence). */
79
+ export function FieldPresenceNote({ entries }) {
80
+ if (entries.length === 0)
81
+ return null;
82
+ const names = [...new Set(entries.map((entry) => entry.actorName ?? translate('cms.presence.someone')))];
83
+ return (_jsxs(Row, { gap: "1", align: "center", className: "text-info", "data-testid": "field-presence", children: [_jsx(Users, { "aria-hidden": true, className: "size-3" }), _jsx(Text, { size: "xs", className: "text-info", children: names.length === 1
84
+ ? translate('cms.presence.inField', { name: names[0] })
85
+ : translate('cms.presence.inFieldMany', { names: names.join(', ') }) })] }));
86
+ }
87
+ //# sourceMappingURL=field-collaboration.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"field-collaboration.js","sourceRoot":"","sources":["../../../src/components/cms/field-collaboration.tsx"],"names":[],"mappings":"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","sourcesContent":["import type { FocusEvent, KeyboardEvent, ReactNode } from 'react'\nimport { AlertTriangle, Users } from 'lucide-react'\nimport { valuePathsOverlap } from '@elytracms/core/cms-core'\nimport type { ValuePath } from '@elytracms/core/cms-core'\nimport type { PresenceEntry } from '@elytracms/core/persistence'\nimport { Button, Row, Stack, Text } from '@elytracms/studio/ui'\nimport { translate } from '@elytracms/studio/lib/i18n'\nimport type { DocumentConflict } from '@elytracms/studio/lib/operations'\n\n/**\n * EC-595 A2 — the field's collaboration chrome: the commit boundary (focus / blur /\n * Enter → the commit policy, presence), the per-field conflict (\"changed by X — take\n * theirs / keep mine\"), and the thin presence note (\"X is in this field\").\n */\n\n/**\n * The commit boundary around one field's control: focus entering it, focus LEAVING it\n * (to anywhere outside — a picker's portal inside the control counts as inside only\n * while its DOM is), and Enter in a single-line input are the triggers the field's\n * commit policy reads (`commit-policy.ts`). A wrapper, so every control — built-in or a\n * project renderer — is covered without knowing about commits.\n */\nexport function FieldCommitBoundary({\n onFocusField,\n onBlurField,\n onEnter,\n children,\n}: {\n onFocusField: () => void\n onBlurField: () => void\n onEnter: () => void\n children: ReactNode\n}) {\n return (\n <div\n data-commit-boundary=\"\"\n onFocus={(event: FocusEvent<HTMLDivElement>) => {\n if (!event.currentTarget.contains(event.relatedTarget as Node | null)) onFocusField()\n }}\n onBlur={(event: FocusEvent<HTMLDivElement>) => {\n if (!event.currentTarget.contains(event.relatedTarget as Node | null)) onBlurField()\n }}\n onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {\n if (event.key === 'Enter' && event.target instanceof HTMLInputElement) onEnter()\n }}\n >\n {children}\n </div>\n )\n}\n\n/** Whether a position belongs to a field row (the field, any of its locale buckets / sub-positions). */\nexport function pathInField(path: ValuePath, field: string, locale: string | undefined): boolean {\n const head = path[0]\n if (!head || head.kind !== 'field' || head.name !== field) return false\n const bucket = path[1]\n if (bucket?.kind === 'locale' && locale !== undefined) return bucket.locale === locale\n return true\n}\n\n/** The display name of a conflict's actor: a present colleague's name, \"you (another tab)\", or someone. */\nexport function conflictActorName(\n conflict: DocumentConflict,\n presence: readonly PresenceEntry[],\n selfActor: string | undefined,\n): string {\n if (conflict.actor !== undefined) {\n const named = presence.find((entry) => entry.actor === conflict.actor && entry.actorName)\n if (named?.actorName) return named.actorName\n if (selfActor !== undefined && conflict.actor === selfActor) return translate('cms.conflict.youInAnotherTab')\n }\n return translate('cms.conflict.someoneElse')\n}\n\n/** A short, plain rendering of \"theirs\" for the notice (scalars verbatim, structures summarised). */\nexport function conflictValuePreview(value: unknown): string {\n if (value === undefined || value === null || value === '') return translate('cms.conflict.empty')\n if (typeof value === 'string') return value.length > 80 ? `${value.slice(0, 79)}…` : value\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n return translate('cms.conflict.structured')\n}\n\n/**\n * The per-field conflict (EC-595 binding point 5): a path this session changed was\n * changed by someone else first, and the store refused it — the rest of the edit applied.\n * The field keeps showing MINE until the user decides: take theirs (adopt the server\n * value) or keep mine (re-send it at the current revision — a visible, second write).\n * Never a whole-document dialog.\n */\nexport function FieldConflictNotice({\n conflict,\n actorName,\n onTakeTheirs,\n onKeepMine,\n}: {\n conflict: DocumentConflict\n actorName: string\n onTakeTheirs: () => void\n onKeepMine: () => void\n}) {\n const gone = conflict.reason === 'invalid' || (conflict.theirs === undefined && conflict.patch.op !== 'unset')\n return (\n <Stack\n gap=\"1\"\n className=\"rounded-md border border-warning/40 bg-warning/10 p-2\"\n data-testid=\"field-conflict\"\n role=\"alert\"\n >\n <Row gap=\"2\" align=\"start\">\n <AlertTriangle aria-hidden className=\"mt-0.5 size-3.5 shrink-0 text-warning\" />\n <Text size=\"xs\">\n {gone\n ? translate('cms.conflict.removedBy', { name: actorName })\n : translate('cms.conflict.changedBy', { name: actorName })}\n </Text>\n </Row>\n {gone ? null : (\n <Text size=\"xs\" tone=\"muted\" className=\"pl-5\" data-testid=\"field-conflict-theirs\">\n {translate('cms.conflict.theirs', { value: conflictValuePreview(conflict.theirs) })}\n </Text>\n )}\n <Row gap=\"2\" className=\"pl-5\">\n <Button size=\"sm\" variant=\"outline\" onClick={onTakeTheirs} data-testid=\"field-conflict-theirs-button\">\n {translate('cms.conflict.takeTheirs')}\n </Button>\n <Button size=\"sm\" variant=\"ghost\" onClick={onKeepMine} data-testid=\"field-conflict-mine-button\">\n {translate('cms.conflict.keepMine')}\n </Button>\n </Row>\n </Stack>\n )\n}\n\n/** The OTHER sessions whose focused field is this one. */\nexport function presentInField(\n presence: readonly PresenceEntry[],\n path: ValuePath,\n): PresenceEntry[] {\n return presence.filter((entry) => entry.path !== undefined && valuePathsOverlap(entry.path, path))\n}\n\n/** \"X is in this field\" — the signal that keeps two editors out of one field (thin presence). */\nexport function FieldPresenceNote({ entries }: { entries: readonly PresenceEntry[] }) {\n if (entries.length === 0) return null\n const names = [...new Set(entries.map((entry) => entry.actorName ?? translate('cms.presence.someone')))]\n return (\n <Row gap=\"1\" align=\"center\" className=\"text-info\" data-testid=\"field-presence\">\n <Users aria-hidden className=\"size-3\" />\n <Text size=\"xs\" className=\"text-info\">\n {names.length === 1\n ? translate('cms.presence.inField', { name: names[0]! })\n : translate('cms.presence.inFieldMany', { names: names.join(', ') })}\n </Text>\n </Row>\n )\n}\n"]}
@@ -1,4 +1,4 @@
1
- import type { ObjectFieldDef, SelectFieldDef } from '@elytracms/core/cms-core';
1
+ import type { DocumentRef, FieldDef, ObjectFieldDef, SelectFieldDef } from '@elytracms/core/cms-core';
2
2
  /**
3
3
  * Pure companions of the document-editor field controls (EC-330, split from
4
4
  * `field-control.tsx`): the repeater reorder/summary/shape decisions and the
@@ -21,6 +21,13 @@ import type { ObjectFieldDef, SelectFieldDef } from '@elytracms/core/cms-core';
21
21
  * on the next click (measured, round2-B §Q8 1(c)).
22
22
  */
23
23
  export declare function selectedOptions(field: SelectFieldDef, value: unknown): string[];
24
+ /**
25
+ * The value a relation pick writes (EC-589 / EC-586): the typed reference envelope — a
26
+ * list for a `multiple` relation, one envelope (or nothing) for a single one. A
27
+ * reference is an envelope from the first write, so the unsaved draft never holds a
28
+ * bare `{ collection, id }` (round3-D §5 (i): the picker used to write one).
29
+ */
30
+ export declare function relationPickValue(multiple: boolean, next: readonly DocumentRef[]): unknown;
24
31
  /** Toggle one option of a multi-select; the next value (an emptied list is absent). */
25
32
  export declare function toggleSelectOption(field: SelectFieldDef, value: unknown, option: string): string[] | undefined;
26
33
  export declare function reorder<T>(list: readonly T[], from: number, to: number): T[];
@@ -35,6 +42,20 @@ export declare function objectItemSummary(fields: readonly {
35
42
  }[], item: Record<string, unknown>, index: number): string;
36
43
  export declare const depthRail: (d: number) => string;
37
44
  export declare const depthTint: (d: number) => string;
45
+ /**
46
+ * The table's columns: the sub-fields an item SHOWS. A statically hidden sub-field
47
+ * (`form.hidden: true`) is hidden in every row, so it is no column — exactly as the card
48
+ * layout omits it and the validator skips it (EC-582, RULES D7: the table rendered an
49
+ * editable column for it).
50
+ */
51
+ export declare function tableColumnsOf(field: ObjectFieldDef): FieldDef[];
52
+ /**
53
+ * Whether a sub-field's control is locked: its container is, or its own declaration says
54
+ * so — the rule a top-level field (`isFieldReadOnly`) and a prop (`spec.form.readOnly`)
55
+ * already follow, at every sub-field position (EC-582, RULES U9: an object or asset
56
+ * placement sub-field's own `form.readOnly` was ignored — only the parent flag reached it).
57
+ */
58
+ export declare function isSubFieldReadOnly(containerReadOnly: boolean, sub: FieldDef): boolean;
38
59
  export declare function isTabularObjectType(field: ObjectFieldDef): boolean;
39
60
  /**
40
61
  * EC-295: the `options` control — a `select` shown as inline segmented buttons
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { selfTypeFieldValue } from '@elytracms/core/cms-core';
2
+ import { referenceNode, selfTypeFieldValue } from '@elytracms/core/cms-core';
3
3
  import { Button, Row } from '@elytracms/studio/ui';
4
4
  import { localizedLabel } from '@elytracms/studio/lib/i18n';
5
5
  import { fieldLabel } from './cms-labels.js';
@@ -28,6 +28,16 @@ export function selectedOptions(field, value) {
28
28
  const canonical = selfTypeFieldValue(field, value);
29
29
  return Array.isArray(canonical) ? canonical.filter((v) => typeof v === 'string') : [];
30
30
  }
31
+ /**
32
+ * The value a relation pick writes (EC-589 / EC-586): the typed reference envelope — a
33
+ * list for a `multiple` relation, one envelope (or nothing) for a single one. A
34
+ * reference is an envelope from the first write, so the unsaved draft never holds a
35
+ * bare `{ collection, id }` (round3-D §5 (i): the picker used to write one).
36
+ */
37
+ export function relationPickValue(multiple, next) {
38
+ const refs = next.map(referenceNode);
39
+ return multiple ? refs : (refs[0] ?? undefined);
40
+ }
31
41
  /** Toggle one option of a multi-select; the next value (an emptied list is absent). */
32
42
  export function toggleSelectOption(field, value, option) {
33
43
  const selected = selectedOptions(field, value);
@@ -95,10 +105,29 @@ export const depthTint = (d) => DEPTH_TINT[d % DEPTH_TINT.length];
95
105
  // card path — a table has no place to show/hide a column per row.
96
106
  const TABULAR_FIELD_TYPES = new Set(['text', 'number', 'boolean', 'select', 'date']);
97
107
  const MAX_TABLE_COLUMNS = 4;
108
+ /**
109
+ * The table's columns: the sub-fields an item SHOWS. A statically hidden sub-field
110
+ * (`form.hidden: true`) is hidden in every row, so it is no column — exactly as the card
111
+ * layout omits it and the validator skips it (EC-582, RULES D7: the table rendered an
112
+ * editable column for it).
113
+ */
114
+ export function tableColumnsOf(field) {
115
+ return field.fields.filter((f) => f.form?.hidden !== true);
116
+ }
117
+ /**
118
+ * Whether a sub-field's control is locked: its container is, or its own declaration says
119
+ * so — the rule a top-level field (`isFieldReadOnly`) and a prop (`spec.form.readOnly`)
120
+ * already follow, at every sub-field position (EC-582, RULES U9: an object or asset
121
+ * placement sub-field's own `form.readOnly` was ignored — only the parent flag reached it).
122
+ */
123
+ export function isSubFieldReadOnly(containerReadOnly, sub) {
124
+ return containerReadOnly || sub.form?.readOnly === true;
125
+ }
98
126
  export function isTabularObjectType(field) {
99
- return (field.fields.length > 0 &&
100
- field.fields.length <= MAX_TABLE_COLUMNS &&
101
- field.fields.every((f) => TABULAR_FIELD_TYPES.has(f.type) && typeof f.form?.hidden !== 'function'));
127
+ const columns = tableColumnsOf(field);
128
+ return (columns.length > 0 &&
129
+ columns.length <= MAX_TABLE_COLUMNS &&
130
+ columns.every((f) => TABULAR_FIELD_TYPES.has(f.type) && typeof f.form?.hidden !== 'function'));
102
131
  }
103
132
  /**
104
133
  * EC-295: the `options` control — a `select` shown as inline segmented buttons
@@ -1 +1 @@
1
- {"version":3,"file":"field-control-lib.js","sourceRoot":"","sources":["../../../src/components/cms/field-control-lib.tsx"],"names":[],"mappings":"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","sourcesContent":["import { selfTypeFieldValue } from '@elytracms/core/cms-core'\nimport type { ObjectFieldDef, SelectFieldDef } from '@elytracms/core/cms-core'\nimport { Button, Row } from '@elytracms/studio/ui'\nimport { localizedLabel } from '@elytracms/studio/lib/i18n'\nimport { fieldLabel } from './cms-labels'\n\n/**\n * Pure companions of the document-editor field controls (EC-330, split from\n * `field-control.tsx`): the repeater reorder/summary/shape decisions and the\n * depth-encoding palette the object-array editors render from, plus the\n * `options` segmented control. Small and dependency-light so the behavioural\n * pins in `field-control.test.ts` reach them without pulling the whole\n * field-control dispatcher.\n */\n\n/**\n * Move `from` → `to`, returning a NEW array (all elements preserved). The repeater\n * applies the same `(from, to)` to its value array AND its parallel stable-key array,\n * so the two stay aligned through a reorder — a row's child/DOM state (an open\n * relation/asset picker, focus) never mis-associates (EC-253 C2, EC-294).\n */\n/**\n * The options a multi-select currently holds, read through THE write canonicaliser\n * (EC-586) — so a legacy scalar (`'news'`, written by the old bulk \"set field\") shows\n * as checked instead of nothing, and the next toggle keeps it. The form used to read\n * `Array.isArray(value) ? value : []`, which showed the scalar unchecked and DROPPED it\n * on the next click (measured, round2-B §Q8 1(c)).\n */\nexport function selectedOptions(field: SelectFieldDef, value: unknown): string[] {\n const canonical = selfTypeFieldValue(field, value)\n return Array.isArray(canonical) ? canonical.filter((v): v is string => typeof v === 'string') : []\n}\n\n/** Toggle one option of a multi-select; the next value (an emptied list is absent). */\nexport function toggleSelectOption(field: SelectFieldDef, value: unknown, option: string): string[] | undefined {\n const selected = selectedOptions(field, value)\n const next = selected.includes(option)\n ? selected.filter((v) => v !== option)\n : [...selected, option]\n return next.length === 0 ? undefined : next\n}\n\nexport function reorder<T>(list: readonly T[], from: number, to: number): T[] {\n const next = list.slice()\n const [moved] = next.splice(from, 1)\n if (moved !== undefined) next.splice(to, 0, moved)\n return next\n}\n\nconst SUMMARY_PREFERRED_FIELDS = ['title', 'label', 'name', 'heading', 'text', 'linkType']\n\n/**\n * A scannable one-line summary for a collapsed repeater item (EC-294): the first\n * meaningful scalar subfield value — preferring title-ish field names — else `#n`.\n * Pure (no store): summarises from the item's own scalar values, so it works for\n * any object shape without resolving relations/assets.\n */\nexport function objectItemSummary(\n fields: readonly { name: string }[],\n item: Record<string, unknown>,\n index: number,\n): string {\n const scalar = (name: string): string | null => {\n const v = item[name]\n if (typeof v === 'string' && v.trim()) return v.trim()\n if (typeof v === 'number' || typeof v === 'boolean') return String(v)\n return null\n }\n const names = fields.map((f) => f.name)\n for (const name of SUMMARY_PREFERRED_FIELDS) {\n if (names.includes(name)) {\n const s = scalar(name)\n if (s) return truncateSummary(s)\n }\n }\n for (const name of names) {\n const s = scalar(name)\n if (s) return truncateSummary(s)\n }\n return `#${index + 1}`\n}\n\nfunction truncateSummary(s: string, max = 60): string {\n return s.length > max ? `${s.slice(0, max - 1)}…` : s\n}\n\n// (B) Depth-keyed visuals: a coloured left rail + a matching faint tint, DISTINCT\n// per level so nesting reads at a glance. The outermost array (depth 0) starts at\n// the brand primary — no more neutral, un-encoded first level — then a fixed\n// high-contrast rotation whose adjacent hues never collide (primary→amber→green→red;\n// note `info` is a near-primary blue, so it is deliberately NOT adjacent to primary).\n// It cycles (mod) so pathological depth still renders. Literal classes so Tailwind emits them.\nconst DEPTH_RAIL = ['border-l-primary', 'border-l-warning', 'border-l-success', 'border-l-destructive']\nconst DEPTH_TINT = ['bg-primary/5', 'bg-warning/5', 'bg-success/5', 'bg-destructive/5']\n// `d` is always ≥ 0, so `d % length` is in-bounds — the assertion is sound and gives\n// callers a definite `string` (cn tolerates undefined, but tests read the value).\nexport const depthRail = (d: number) => DEPTH_RAIL[d % DEPTH_RAIL.length]!\nexport const depthTint = (d: number) => DEPTH_TINT[d % DEPTH_TINT.length]!\n\n// (C) An object array renders as a compact TABLE when every subfield is a scalar (a\n// flat row) and there are few enough columns to read; richer/nested object types stay\n// as cards with depth encoding (B). Conditional (function-hidden) subfields force the\n// card path — a table has no place to show/hide a column per row.\nconst TABULAR_FIELD_TYPES = new Set(['text', 'number', 'boolean', 'select', 'date'])\nconst MAX_TABLE_COLUMNS = 4\nexport function isTabularObjectType(field: ObjectFieldDef): boolean {\n return (\n field.fields.length > 0 &&\n field.fields.length <= MAX_TABLE_COLUMNS &&\n field.fields.every(\n (f) => TABULAR_FIELD_TYPES.has(f.type) && typeof f.form?.hidden !== 'function',\n )\n )\n}\n\n/**\n * EC-295: the `options` control — a `select` shown as inline segmented buttons\n * (`[[Page]] [URL] [File]`) instead of a dropdown. Engine built-in (no consumer\n * registration); single-select only. Each option shows `label ?? value` and a\n * `title` tooltip from the option's `description`; the field `default` + `readOnly`\n * flow through the shared form. Picks one — to allow an empty/unset value use the\n * default dropdown control.\n */\nexport function SegmentedOptions({\n field,\n value,\n onChange,\n readOnly,\n id,\n}: {\n field: SelectFieldDef\n value: unknown\n onChange: (value: unknown) => void\n readOnly: boolean\n id: string\n}) {\n const current = typeof value === 'string' ? value : ''\n return (\n <Row gap=\"1\" wrap role=\"radiogroup\" id={id} aria-label={fieldLabel(field)}>\n {field.options.map((opt) => {\n const active = current === opt.value\n return (\n <Button\n key={opt.value}\n type=\"button\"\n variant={active ? 'default' : 'outline'}\n size=\"sm\"\n role=\"radio\"\n aria-checked={active}\n title={localizedLabel(opt.description) || undefined}\n disabled={readOnly}\n onClick={() => onChange(opt.value)}\n >\n {localizedLabel(opt.label, opt.value)}\n </Button>\n )\n })}\n </Row>\n )\n}\n"]}
1
+ {"version":3,"file":"field-control-lib.js","sourceRoot":"","sources":["../../../src/components/cms/field-control-lib.tsx"],"names":[],"mappings":"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","sourcesContent":["import { referenceNode, selfTypeFieldValue } from '@elytracms/core/cms-core'\nimport type { DocumentRef, FieldDef, ObjectFieldDef, SelectFieldDef } from '@elytracms/core/cms-core'\nimport { Button, Row } from '@elytracms/studio/ui'\nimport { localizedLabel } from '@elytracms/studio/lib/i18n'\nimport { fieldLabel } from './cms-labels'\n\n/**\n * Pure companions of the document-editor field controls (EC-330, split from\n * `field-control.tsx`): the repeater reorder/summary/shape decisions and the\n * depth-encoding palette the object-array editors render from, plus the\n * `options` segmented control. Small and dependency-light so the behavioural\n * pins in `field-control.test.ts` reach them without pulling the whole\n * field-control dispatcher.\n */\n\n/**\n * Move `from` → `to`, returning a NEW array (all elements preserved). The repeater\n * applies the same `(from, to)` to its value array AND its parallel stable-key array,\n * so the two stay aligned through a reorder — a row's child/DOM state (an open\n * relation/asset picker, focus) never mis-associates (EC-253 C2, EC-294).\n */\n/**\n * The options a multi-select currently holds, read through THE write canonicaliser\n * (EC-586) — so a legacy scalar (`'news'`, written by the old bulk \"set field\") shows\n * as checked instead of nothing, and the next toggle keeps it. The form used to read\n * `Array.isArray(value) ? value : []`, which showed the scalar unchecked and DROPPED it\n * on the next click (measured, round2-B §Q8 1(c)).\n */\nexport function selectedOptions(field: SelectFieldDef, value: unknown): string[] {\n const canonical = selfTypeFieldValue(field, value)\n return Array.isArray(canonical) ? canonical.filter((v): v is string => typeof v === 'string') : []\n}\n\n/**\n * The value a relation pick writes (EC-589 / EC-586): the typed reference envelope — a\n * list for a `multiple` relation, one envelope (or nothing) for a single one. A\n * reference is an envelope from the first write, so the unsaved draft never holds a\n * bare `{ collection, id }` (round3-D §5 (i): the picker used to write one).\n */\nexport function relationPickValue(multiple: boolean, next: readonly DocumentRef[]): unknown {\n const refs = next.map(referenceNode)\n return multiple ? refs : (refs[0] ?? undefined)\n}\n\n/** Toggle one option of a multi-select; the next value (an emptied list is absent). */\nexport function toggleSelectOption(field: SelectFieldDef, value: unknown, option: string): string[] | undefined {\n const selected = selectedOptions(field, value)\n const next = selected.includes(option)\n ? selected.filter((v) => v !== option)\n : [...selected, option]\n return next.length === 0 ? undefined : next\n}\n\nexport function reorder<T>(list: readonly T[], from: number, to: number): T[] {\n const next = list.slice()\n const [moved] = next.splice(from, 1)\n if (moved !== undefined) next.splice(to, 0, moved)\n return next\n}\n\nconst SUMMARY_PREFERRED_FIELDS = ['title', 'label', 'name', 'heading', 'text', 'linkType']\n\n/**\n * A scannable one-line summary for a collapsed repeater item (EC-294): the first\n * meaningful scalar subfield value — preferring title-ish field names — else `#n`.\n * Pure (no store): summarises from the item's own scalar values, so it works for\n * any object shape without resolving relations/assets.\n */\nexport function objectItemSummary(\n fields: readonly { name: string }[],\n item: Record<string, unknown>,\n index: number,\n): string {\n const scalar = (name: string): string | null => {\n const v = item[name]\n if (typeof v === 'string' && v.trim()) return v.trim()\n if (typeof v === 'number' || typeof v === 'boolean') return String(v)\n return null\n }\n const names = fields.map((f) => f.name)\n for (const name of SUMMARY_PREFERRED_FIELDS) {\n if (names.includes(name)) {\n const s = scalar(name)\n if (s) return truncateSummary(s)\n }\n }\n for (const name of names) {\n const s = scalar(name)\n if (s) return truncateSummary(s)\n }\n return `#${index + 1}`\n}\n\nfunction truncateSummary(s: string, max = 60): string {\n return s.length > max ? `${s.slice(0, max - 1)}…` : s\n}\n\n// (B) Depth-keyed visuals: a coloured left rail + a matching faint tint, DISTINCT\n// per level so nesting reads at a glance. The outermost array (depth 0) starts at\n// the brand primary — no more neutral, un-encoded first level — then a fixed\n// high-contrast rotation whose adjacent hues never collide (primary→amber→green→red;\n// note `info` is a near-primary blue, so it is deliberately NOT adjacent to primary).\n// It cycles (mod) so pathological depth still renders. Literal classes so Tailwind emits them.\nconst DEPTH_RAIL = ['border-l-primary', 'border-l-warning', 'border-l-success', 'border-l-destructive']\nconst DEPTH_TINT = ['bg-primary/5', 'bg-warning/5', 'bg-success/5', 'bg-destructive/5']\n// `d` is always ≥ 0, so `d % length` is in-bounds — the assertion is sound and gives\n// callers a definite `string` (cn tolerates undefined, but tests read the value).\nexport const depthRail = (d: number) => DEPTH_RAIL[d % DEPTH_RAIL.length]!\nexport const depthTint = (d: number) => DEPTH_TINT[d % DEPTH_TINT.length]!\n\n// (C) An object array renders as a compact TABLE when every subfield is a scalar (a\n// flat row) and there are few enough columns to read; richer/nested object types stay\n// as cards with depth encoding (B). Conditional (function-hidden) subfields force the\n// card path — a table has no place to show/hide a column per row.\nconst TABULAR_FIELD_TYPES = new Set(['text', 'number', 'boolean', 'select', 'date'])\nconst MAX_TABLE_COLUMNS = 4\n\n/**\n * The table's columns: the sub-fields an item SHOWS. A statically hidden sub-field\n * (`form.hidden: true`) is hidden in every row, so it is no column — exactly as the card\n * layout omits it and the validator skips it (EC-582, RULES D7: the table rendered an\n * editable column for it).\n */\nexport function tableColumnsOf(field: ObjectFieldDef): FieldDef[] {\n return field.fields.filter((f) => f.form?.hidden !== true)\n}\n\n/**\n * Whether a sub-field's control is locked: its container is, or its own declaration says\n * so — the rule a top-level field (`isFieldReadOnly`) and a prop (`spec.form.readOnly`)\n * already follow, at every sub-field position (EC-582, RULES U9: an object or asset\n * placement sub-field's own `form.readOnly` was ignored — only the parent flag reached it).\n */\nexport function isSubFieldReadOnly(containerReadOnly: boolean, sub: FieldDef): boolean {\n return containerReadOnly || sub.form?.readOnly === true\n}\n\nexport function isTabularObjectType(field: ObjectFieldDef): boolean {\n const columns = tableColumnsOf(field)\n return (\n columns.length > 0 &&\n columns.length <= MAX_TABLE_COLUMNS &&\n columns.every((f) => TABULAR_FIELD_TYPES.has(f.type) && typeof f.form?.hidden !== 'function')\n )\n}\n\n/**\n * EC-295: the `options` control — a `select` shown as inline segmented buttons\n * (`[[Page]] [URL] [File]`) instead of a dropdown. Engine built-in (no consumer\n * registration); single-select only. Each option shows `label ?? value` and a\n * `title` tooltip from the option's `description`; the field `default` + `readOnly`\n * flow through the shared form. Picks one — to allow an empty/unset value use the\n * default dropdown control.\n */\nexport function SegmentedOptions({\n field,\n value,\n onChange,\n readOnly,\n id,\n}: {\n field: SelectFieldDef\n value: unknown\n onChange: (value: unknown) => void\n readOnly: boolean\n id: string\n}) {\n const current = typeof value === 'string' ? value : ''\n return (\n <Row gap=\"1\" wrap role=\"radiogroup\" id={id} aria-label={fieldLabel(field)}>\n {field.options.map((opt) => {\n const active = current === opt.value\n return (\n <Button\n key={opt.value}\n type=\"button\"\n variant={active ? 'default' : 'outline'}\n size=\"sm\"\n role=\"radio\"\n aria-checked={active}\n title={localizedLabel(opt.description) || undefined}\n disabled={readOnly}\n onClick={() => onChange(opt.value)}\n >\n {localizedLabel(opt.label, opt.value)}\n </Button>\n )\n })}\n </Row>\n )\n}\n"]}