@actuate-media/cms-admin 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (315) hide show
  1. package/LICENSE +21 -21
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +8 -5
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/fields/component-block-helpers.test.d.ts +7 -0
  6. package/dist/__tests__/fields/component-block-helpers.test.d.ts.map +1 -0
  7. package/dist/__tests__/fields/component-block-helpers.test.js +592 -0
  8. package/dist/__tests__/fields/component-block-helpers.test.js.map +1 -0
  9. package/dist/__tests__/layout/primitives.test.d.ts +2 -0
  10. package/dist/__tests__/layout/primitives.test.d.ts.map +1 -0
  11. package/dist/__tests__/layout/primitives.test.js +34 -0
  12. package/dist/__tests__/layout/primitives.test.js.map +1 -0
  13. package/dist/__tests__/lib/cv.test.d.ts +2 -0
  14. package/dist/__tests__/lib/cv.test.d.ts.map +1 -0
  15. package/dist/__tests__/lib/cv.test.js +66 -0
  16. package/dist/__tests__/lib/cv.test.js.map +1 -0
  17. package/dist/actuate-admin.css +1 -1
  18. package/dist/assets/actuate-logo.d.ts +36 -0
  19. package/dist/assets/actuate-logo.d.ts.map +1 -0
  20. package/dist/assets/actuate-logo.js +15 -0
  21. package/dist/assets/actuate-logo.js.map +1 -0
  22. package/dist/components/Breadcrumbs.js +2 -2
  23. package/dist/components/CommandPalette.js +10 -10
  24. package/dist/components/ContentOverviewChart.js +3 -3
  25. package/dist/components/ErrorBoundary.js +1 -1
  26. package/dist/components/FocalPointPicker.js +2 -2
  27. package/dist/components/FolderTree.js +20 -20
  28. package/dist/components/LivePreview.js +3 -3
  29. package/dist/components/LocaleSwitcher.js +1 -1
  30. package/dist/components/MediaPickerModal.js +4 -4
  31. package/dist/components/PresenceIndicator.js +1 -1
  32. package/dist/components/SEOConfigPanel.d.ts +2 -0
  33. package/dist/components/SEOConfigPanel.d.ts.map +1 -0
  34. package/dist/components/SEOConfigPanel.js +174 -0
  35. package/dist/components/SEOConfigPanel.js.map +1 -0
  36. package/dist/components/SEOPanel.js +9 -9
  37. package/dist/components/SEOPerformance.js +2 -2
  38. package/dist/components/SchedulePublishDialog.js +1 -1
  39. package/dist/components/SharePreviewLinkDialog.js +1 -1
  40. package/dist/components/TipTapEditor.js +5 -5
  41. package/dist/components/VersionHistory.js +2 -2
  42. package/dist/components/ui/Badge.d.ts +33 -3
  43. package/dist/components/ui/Badge.d.ts.map +1 -1
  44. package/dist/components/ui/Badge.js +42 -8
  45. package/dist/components/ui/Badge.js.map +1 -1
  46. package/dist/components/ui/Button.d.ts +19 -8
  47. package/dist/components/ui/Button.d.ts.map +1 -1
  48. package/dist/components/ui/Button.js +35 -14
  49. package/dist/components/ui/Button.js.map +1 -1
  50. package/dist/components/ui/Card.d.ts +26 -0
  51. package/dist/components/ui/Card.d.ts.map +1 -0
  52. package/dist/components/ui/Card.js +45 -0
  53. package/dist/components/ui/Card.js.map +1 -0
  54. package/dist/components/ui/DataTable.js +1 -1
  55. package/dist/components/ui/Input.d.ts +15 -0
  56. package/dist/components/ui/Input.d.ts.map +1 -0
  57. package/dist/components/ui/Input.js +23 -0
  58. package/dist/components/ui/Input.js.map +1 -0
  59. package/dist/components/ui/SearchInput.js +1 -1
  60. package/dist/components/ui/Select.d.ts +16 -0
  61. package/dist/components/ui/Select.d.ts.map +1 -0
  62. package/dist/components/ui/Select.js +25 -0
  63. package/dist/components/ui/Select.js.map +1 -0
  64. package/dist/components/ui/Toast.js +1 -1
  65. package/dist/components/ui/index.d.ts +10 -4
  66. package/dist/components/ui/index.d.ts.map +1 -1
  67. package/dist/components/ui/index.js +5 -2
  68. package/dist/components/ui/index.js.map +1 -1
  69. package/dist/fields/BlockBuilderField.js +3 -3
  70. package/dist/fields/ComponentBlockField.d.ts +25 -0
  71. package/dist/fields/ComponentBlockField.d.ts.map +1 -0
  72. package/dist/fields/ComponentBlockField.js +74 -0
  73. package/dist/fields/ComponentBlockField.js.map +1 -0
  74. package/dist/fields/DateField.js +1 -1
  75. package/dist/fields/FieldRenderer.d.ts +3 -0
  76. package/dist/fields/FieldRenderer.d.ts.map +1 -1
  77. package/dist/fields/FieldRenderer.js +3 -1
  78. package/dist/fields/FieldRenderer.js.map +1 -1
  79. package/dist/fields/PropInput.d.ts +14 -0
  80. package/dist/fields/PropInput.d.ts.map +1 -0
  81. package/dist/fields/PropInput.js +163 -0
  82. package/dist/fields/PropInput.js.map +1 -0
  83. package/dist/fields/RelationshipField.js +3 -3
  84. package/dist/fields/TextField.js +1 -1
  85. package/dist/fields/component-block-helpers.d.ts +96 -0
  86. package/dist/fields/component-block-helpers.d.ts.map +1 -0
  87. package/dist/fields/component-block-helpers.js +323 -0
  88. package/dist/fields/component-block-helpers.js.map +1 -0
  89. package/dist/fields/index.d.ts +4 -0
  90. package/dist/fields/index.d.ts.map +1 -1
  91. package/dist/fields/index.js +2 -0
  92. package/dist/fields/index.js.map +1 -1
  93. package/dist/index.d.ts +4 -0
  94. package/dist/index.d.ts.map +1 -1
  95. package/dist/index.js +4 -0
  96. package/dist/index.js.map +1 -1
  97. package/dist/layout/Header.js +1 -1
  98. package/dist/layout/Layout.d.ts +14 -0
  99. package/dist/layout/Layout.d.ts.map +1 -1
  100. package/dist/layout/Layout.js +17 -11
  101. package/dist/layout/Layout.js.map +1 -1
  102. package/dist/layout/Sidebar.d.ts.map +1 -1
  103. package/dist/layout/Sidebar.js +21 -11
  104. package/dist/layout/Sidebar.js.map +1 -1
  105. package/dist/layout/primitives/AdminShell.d.ts +43 -0
  106. package/dist/layout/primitives/AdminShell.d.ts.map +1 -0
  107. package/dist/layout/primitives/AdminShell.js +51 -0
  108. package/dist/layout/primitives/AdminShell.js.map +1 -0
  109. package/dist/layout/primitives/Box.d.ts +19 -0
  110. package/dist/layout/primitives/Box.d.ts.map +1 -0
  111. package/dist/layout/primitives/Box.js +12 -0
  112. package/dist/layout/primitives/Box.js.map +1 -0
  113. package/dist/layout/primitives/Cluster.d.ts +27 -0
  114. package/dist/layout/primitives/Cluster.d.ts.map +1 -0
  115. package/dist/layout/primitives/Cluster.js +37 -0
  116. package/dist/layout/primitives/Cluster.js.map +1 -0
  117. package/dist/layout/primitives/Grid.d.ts +45 -0
  118. package/dist/layout/primitives/Grid.d.ts.map +1 -0
  119. package/dist/layout/primitives/Grid.js +59 -0
  120. package/dist/layout/primitives/Grid.js.map +1 -0
  121. package/dist/layout/primitives/PageContainer.d.ts +36 -0
  122. package/dist/layout/primitives/PageContainer.d.ts.map +1 -0
  123. package/dist/layout/primitives/PageContainer.js +41 -0
  124. package/dist/layout/primitives/PageContainer.js.map +1 -0
  125. package/dist/layout/primitives/Split.d.ts +34 -0
  126. package/dist/layout/primitives/Split.d.ts.map +1 -0
  127. package/dist/layout/primitives/Split.js +27 -0
  128. package/dist/layout/primitives/Split.js.map +1 -0
  129. package/dist/layout/primitives/Stack.d.ts +23 -0
  130. package/dist/layout/primitives/Stack.d.ts.map +1 -0
  131. package/dist/layout/primitives/Stack.js +34 -0
  132. package/dist/layout/primitives/Stack.js.map +1 -0
  133. package/dist/layout/primitives/index.d.ts +30 -0
  134. package/dist/layout/primitives/index.d.ts.map +1 -0
  135. package/dist/layout/primitives/index.js +22 -0
  136. package/dist/layout/primitives/index.js.map +1 -0
  137. package/dist/layout/primitives/tokens.d.ts +48 -0
  138. package/dist/layout/primitives/tokens.d.ts.map +1 -0
  139. package/dist/layout/primitives/tokens.js +54 -0
  140. package/dist/layout/primitives/tokens.js.map +1 -0
  141. package/dist/lib/cv.d.ts +53 -0
  142. package/dist/lib/cv.d.ts.map +1 -0
  143. package/dist/lib/cv.js +39 -0
  144. package/dist/lib/cv.js.map +1 -0
  145. package/dist/views/ApiKeys.js +7 -7
  146. package/dist/views/CollectionList.js +8 -8
  147. package/dist/views/Dashboard.d.ts.map +1 -1
  148. package/dist/views/Dashboard.js +333 -78
  149. package/dist/views/Dashboard.js.map +1 -1
  150. package/dist/views/DocumentEdit.js +3 -3
  151. package/dist/views/ForgotPassword.js +2 -2
  152. package/dist/views/FormEditor.js +5 -5
  153. package/dist/views/FormSubmissions.js +6 -6
  154. package/dist/views/Forms.js +2 -2
  155. package/dist/views/Login.d.ts +16 -1
  156. package/dist/views/Login.d.ts.map +1 -1
  157. package/dist/views/Login.js +17 -7
  158. package/dist/views/Login.js.map +1 -1
  159. package/dist/views/MediaBrowser.js +16 -16
  160. package/dist/views/PageEditor.js +2 -2
  161. package/dist/views/Pages.js +10 -10
  162. package/dist/views/PostEditor.js +2 -2
  163. package/dist/views/Posts.js +4 -4
  164. package/dist/views/Redirects.js +4 -4
  165. package/dist/views/ResetPassword.js +2 -2
  166. package/dist/views/SEO.js +6 -6
  167. package/dist/views/ScriptTagEditor.js +4 -4
  168. package/dist/views/ScriptTags.js +2 -2
  169. package/dist/views/Settings.d.ts.map +1 -1
  170. package/dist/views/Settings.js +9 -8
  171. package/dist/views/Settings.js.map +1 -1
  172. package/dist/views/SetupWizard.js +2 -2
  173. package/dist/views/Users.js +4 -4
  174. package/dist/views/page-builder/AIBlockAssist.js +1 -1
  175. package/dist/views/page-builder/AIGenerateDialog.js +10 -10
  176. package/dist/views/page-builder/BlockEditor.js +10 -10
  177. package/dist/views/page-builder/BlockPicker.js +4 -4
  178. package/dist/views/page-builder/BottomBar.js +1 -1
  179. package/dist/views/page-builder/BuilderToolbar.js +2 -2
  180. package/dist/views/page-builder/ContextPanel.js +2 -2
  181. package/dist/views/page-builder/DesignScore.js +9 -9
  182. package/dist/views/page-builder/NodeSettings.js +8 -8
  183. package/dist/views/page-builder/PageBuilder.js +3 -3
  184. package/dist/views/page-builder/PageSettings.js +1 -1
  185. package/dist/views/page-builder/PageTemplates.js +2 -2
  186. package/dist/views/page-builder/SEOPanel.js +13 -13
  187. package/dist/views/page-builder/SavedSections.js +5 -5
  188. package/dist/views/page-builder/TemplatePicker.js +2 -2
  189. package/dist/views/page-builder/block-renderers/CTAPreview.js +5 -5
  190. package/dist/views/page-builder/block-renderers/CardsPreview.js +1 -1
  191. package/dist/views/page-builder/block-renderers/CodePreview.js +1 -1
  192. package/dist/views/page-builder/block-renderers/FAQPreview.js +3 -3
  193. package/dist/views/page-builder/block-renderers/FallbackPreview.js +1 -1
  194. package/dist/views/page-builder/block-renderers/FormPreview.js +3 -3
  195. package/dist/views/page-builder/block-renderers/GalleryPreview.js +5 -5
  196. package/dist/views/page-builder/block-renderers/HeroPreview.js +3 -3
  197. package/dist/views/page-builder/block-renderers/ImagePreview.js +3 -3
  198. package/dist/views/page-builder/block-renderers/TextPreview.js +3 -3
  199. package/dist/views/page-builder/block-renderers/VideoPreview.js +4 -4
  200. package/dist/views/page-builder/canvas/BlockRenderer.js +1 -1
  201. package/dist/views/page-builder/canvas/BuilderCanvas.js +3 -3
  202. package/dist/views/page-builder/canvas/ColumnRenderer.js +2 -2
  203. package/dist/views/page-builder/canvas/ContainerRenderer.js +2 -2
  204. package/dist/views/page-builder/canvas/RowRenderer.js +2 -2
  205. package/dist/views/page-builder/canvas/SectionRenderer.js +2 -2
  206. package/package.json +14 -3
  207. package/src/AdminRoot.tsx +21 -11
  208. package/src/__tests__/fields/component-block-helpers.test.ts +674 -0
  209. package/src/__tests__/layout/primitives.test.ts +37 -0
  210. package/src/__tests__/lib/cv.test.ts +74 -0
  211. package/src/assets/actuate-logo.tsx +72 -0
  212. package/src/components/Breadcrumbs.tsx +6 -6
  213. package/src/components/CommandPalette.tsx +34 -34
  214. package/src/components/ContentOverviewChart.tsx +3 -3
  215. package/src/components/ErrorBoundary.tsx +3 -3
  216. package/src/components/FocalPointPicker.tsx +4 -4
  217. package/src/components/FolderTree.tsx +38 -38
  218. package/src/components/LivePreview.tsx +16 -16
  219. package/src/components/LocaleSwitcher.tsx +7 -7
  220. package/src/components/MediaPickerModal.tsx +21 -21
  221. package/src/components/PresenceIndicator.tsx +2 -2
  222. package/src/components/SEOConfigPanel.tsx +582 -0
  223. package/src/components/SEOPanel.tsx +46 -46
  224. package/src/components/SEOPerformance.tsx +21 -21
  225. package/src/components/SchedulePublishDialog.tsx +4 -4
  226. package/src/components/SharePreviewLinkDialog.tsx +1 -1
  227. package/src/components/TipTapEditor.tsx +33 -33
  228. package/src/components/VersionHistory.tsx +16 -16
  229. package/src/components/ui/Badge.tsx +66 -14
  230. package/src/components/ui/Button.tsx +70 -33
  231. package/src/components/ui/Card.tsx +101 -0
  232. package/src/components/ui/DataTable.tsx +1 -1
  233. package/src/components/ui/Input.tsx +35 -0
  234. package/src/components/ui/SearchInput.tsx +4 -4
  235. package/src/components/ui/Select.tsx +56 -0
  236. package/src/components/ui/Toast.tsx +1 -1
  237. package/src/components/ui/index.ts +18 -4
  238. package/src/fields/BlockBuilderField.tsx +3 -3
  239. package/src/fields/ComponentBlockField.tsx +179 -0
  240. package/src/fields/DateField.tsx +1 -1
  241. package/src/fields/FieldRenderer.tsx +8 -0
  242. package/src/fields/PropInput.tsx +552 -0
  243. package/src/fields/RelationshipField.tsx +10 -10
  244. package/src/fields/TextField.tsx +1 -1
  245. package/src/fields/component-block-helpers.ts +341 -0
  246. package/src/fields/index.ts +4 -0
  247. package/src/index.ts +35 -0
  248. package/src/layout/Header.tsx +28 -28
  249. package/src/layout/Layout.tsx +39 -46
  250. package/src/layout/Sidebar.tsx +37 -64
  251. package/src/layout/primitives/AdminShell.tsx +118 -0
  252. package/src/layout/primitives/Box.tsx +30 -0
  253. package/src/layout/primitives/Cluster.tsx +74 -0
  254. package/src/layout/primitives/Grid.tsx +120 -0
  255. package/src/layout/primitives/PageContainer.tsx +96 -0
  256. package/src/layout/primitives/Split.tsx +73 -0
  257. package/src/layout/primitives/Stack.tsx +67 -0
  258. package/src/layout/primitives/index.ts +36 -0
  259. package/src/layout/primitives/tokens.ts +76 -0
  260. package/src/lib/cv.ts +96 -0
  261. package/src/styles/build-input.css +1 -1
  262. package/src/views/ApiKeys.tsx +57 -57
  263. package/src/views/CollectionList.tsx +30 -30
  264. package/src/views/Dashboard.tsx +737 -186
  265. package/src/views/DocumentEdit.tsx +9 -9
  266. package/src/views/ForgotPassword.tsx +18 -18
  267. package/src/views/FormEditor.tsx +75 -75
  268. package/src/views/FormSubmissions.tsx +76 -76
  269. package/src/views/Forms.tsx +27 -27
  270. package/src/views/Login.tsx +65 -25
  271. package/src/views/MediaBrowser.tsx +127 -127
  272. package/src/views/PageEditor.tsx +25 -25
  273. package/src/views/Pages.tsx +59 -59
  274. package/src/views/PostEditor.tsx +37 -37
  275. package/src/views/Posts.tsx +48 -48
  276. package/src/views/Redirects.tsx +21 -21
  277. package/src/views/ResetPassword.tsx +28 -28
  278. package/src/views/SEO.tsx +144 -144
  279. package/src/views/ScriptTagEditor.tsx +24 -24
  280. package/src/views/ScriptTags.tsx +10 -10
  281. package/src/views/Settings.tsx +88 -80
  282. package/src/views/SetupWizard.tsx +28 -28
  283. package/src/views/Users.tsx +20 -20
  284. package/src/views/page-builder/AIBlockAssist.tsx +1 -1
  285. package/src/views/page-builder/AIGenerateDialog.tsx +63 -63
  286. package/src/views/page-builder/BlockEditor.tsx +26 -26
  287. package/src/views/page-builder/BlockPicker.tsx +22 -22
  288. package/src/views/page-builder/BottomBar.tsx +8 -8
  289. package/src/views/page-builder/BuilderToolbar.tsx +17 -17
  290. package/src/views/page-builder/ContextPanel.tsx +3 -3
  291. package/src/views/page-builder/DesignScore.tsx +21 -21
  292. package/src/views/page-builder/NodeSettings.tsx +27 -27
  293. package/src/views/page-builder/PageBuilder.tsx +11 -11
  294. package/src/views/page-builder/PageSettings.tsx +4 -4
  295. package/src/views/page-builder/PageTemplates.tsx +18 -18
  296. package/src/views/page-builder/SEOPanel.tsx +53 -53
  297. package/src/views/page-builder/SavedSections.tsx +37 -37
  298. package/src/views/page-builder/TemplatePicker.tsx +17 -17
  299. package/src/views/page-builder/block-renderers/CTAPreview.tsx +13 -13
  300. package/src/views/page-builder/block-renderers/CardsPreview.tsx +5 -5
  301. package/src/views/page-builder/block-renderers/CodePreview.tsx +6 -6
  302. package/src/views/page-builder/block-renderers/FAQPreview.tsx +13 -13
  303. package/src/views/page-builder/block-renderers/FallbackPreview.tsx +3 -3
  304. package/src/views/page-builder/block-renderers/FormPreview.tsx +20 -20
  305. package/src/views/page-builder/block-renderers/GalleryPreview.tsx +8 -8
  306. package/src/views/page-builder/block-renderers/HeroPreview.tsx +16 -16
  307. package/src/views/page-builder/block-renderers/ImagePreview.tsx +4 -4
  308. package/src/views/page-builder/block-renderers/TextPreview.tsx +14 -14
  309. package/src/views/page-builder/block-renderers/VideoPreview.tsx +12 -12
  310. package/src/views/page-builder/canvas/BlockRenderer.tsx +4 -4
  311. package/src/views/page-builder/canvas/BuilderCanvas.tsx +6 -6
  312. package/src/views/page-builder/canvas/ColumnRenderer.tsx +3 -3
  313. package/src/views/page-builder/canvas/ContainerRenderer.tsx +2 -2
  314. package/src/views/page-builder/canvas/RowRenderer.tsx +2 -2
  315. package/src/views/page-builder/canvas/SectionRenderer.tsx +2 -2
@@ -0,0 +1,552 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Recursive input dispatcher for component-block props. Given a
5
+ * {@link PropSpec} and the currently stored value, renders the correct
6
+ * editor:
7
+ *
8
+ * | PropType.kind | Editor |
9
+ * | --------------------- | ---------------------------------------------- |
10
+ * | `string` | single-line text input |
11
+ * | `number` | native number input |
12
+ * | `boolean` | switch (ToggleField look) |
13
+ * | `enum` | select with the literal values |
14
+ * | `literal` | readonly display (it's the only allowed value) |
15
+ * | `array` | add/remove list of recursive PropInputs |
16
+ * | `object` | bordered group of recursive PropInputs |
17
+ * | `union` (discriminated) | variant picker + recursive object form |
18
+ * | `union` (other) | JSON textarea + hint |
19
+ * | `reference` | JSON textarea + "unresolved" hint |
20
+ * | `unknown` | JSON textarea + warning |
21
+ *
22
+ * The component is intentionally small and self-contained — no calls
23
+ * into cms-core; the parent {@link ComponentBlockField} runs validation
24
+ * via `validateComponentBlockValue` and pipes per-prop errors back in
25
+ * via the `errors` prop.
26
+ */
27
+
28
+ import {
29
+ detectDiscriminator,
30
+ findVariant,
31
+ } from '@actuate-media/component-blocks/discriminated-union'
32
+ import type { DiscriminatedUnion } from '@actuate-media/component-blocks/discriminated-union'
33
+ import type { PropSpec, PropType } from '@actuate-media/component-blocks'
34
+
35
+ import {
36
+ defaultForType,
37
+ parseEnumSelection,
38
+ safeJsonStringify,
39
+ switchUnionVariant,
40
+ } from './component-block-helpers.js'
41
+
42
+ export { defaultForType }
43
+
44
+ export interface PropInputProps {
45
+ prop: PropSpec
46
+ value: unknown
47
+ onChange: (value: unknown) => void
48
+ /** Map keyed by `propPath` (dot-joined) for nested error display. */
49
+ errors?: Record<string, string>
50
+ /** Dot-separated path for the field; used as the error map key. */
51
+ path?: string
52
+ }
53
+
54
+ export function PropInput({ prop, value, onChange, errors, path }: PropInputProps) {
55
+ const fullPath = path ?? prop.name
56
+ const safeErrors = errors ?? EMPTY_ERRORS
57
+ const error = safeErrors[fullPath]
58
+ return (
59
+ <div className="space-y-1">
60
+ <PropInputBody
61
+ prop={prop}
62
+ value={value}
63
+ onChange={onChange}
64
+ errors={safeErrors}
65
+ path={fullPath}
66
+ />
67
+ {prop.description && !error ? (
68
+ <p className="text-xs text-[var(--muted-foreground)]">{prop.description}</p>
69
+ ) : null}
70
+ {error ? <p className="text-xs text-[var(--destructive)]">{error}</p> : null}
71
+ </div>
72
+ )
73
+ }
74
+
75
+ const EMPTY_ERRORS: Record<string, string> = Object.freeze({}) as Record<string, string>
76
+
77
+ interface PropInputBodyProps {
78
+ prop: PropSpec
79
+ value: unknown
80
+ onChange: (value: unknown) => void
81
+ errors: Record<string, string>
82
+ path: string
83
+ }
84
+
85
+ function PropInputBody({ prop, value, onChange, errors, path }: PropInputBodyProps) {
86
+ const type = prop.type
87
+ switch (type.kind) {
88
+ case 'string':
89
+ return <StringInput label={fieldLabel(prop)} value={value} onChange={onChange} />
90
+ case 'number':
91
+ return <NumberInputControl label={fieldLabel(prop)} value={value} onChange={onChange} />
92
+ case 'boolean':
93
+ return <BooleanInput label={fieldLabel(prop)} value={value} onChange={onChange} />
94
+ case 'enum':
95
+ return (
96
+ <EnumInput
97
+ label={fieldLabel(prop)}
98
+ options={type.values}
99
+ value={value}
100
+ onChange={onChange}
101
+ />
102
+ )
103
+ case 'literal':
104
+ return <LiteralDisplay label={fieldLabel(prop)} literal={type.value} />
105
+ case 'array':
106
+ return (
107
+ <ArrayInput
108
+ label={fieldLabel(prop)}
109
+ itemType={type.itemType}
110
+ itemPath={path}
111
+ value={value}
112
+ onChange={onChange}
113
+ errors={errors}
114
+ />
115
+ )
116
+ case 'object':
117
+ return (
118
+ <ObjectInput
119
+ label={fieldLabel(prop)}
120
+ fields={type.fields}
121
+ value={value}
122
+ onChange={onChange}
123
+ errors={errors}
124
+ path={path}
125
+ />
126
+ )
127
+ case 'union': {
128
+ const detected = detectDiscriminator(type)
129
+ if (detected) {
130
+ return (
131
+ <DiscriminatedUnionInput
132
+ label={fieldLabel(prop)}
133
+ union={detected}
134
+ value={value}
135
+ onChange={onChange}
136
+ errors={errors}
137
+ path={path}
138
+ />
139
+ )
140
+ }
141
+ return (
142
+ <JsonFallback
143
+ label={fieldLabel(prop)}
144
+ value={value}
145
+ onChange={onChange}
146
+ hint={fallbackHint(type)}
147
+ />
148
+ )
149
+ }
150
+ case 'reference':
151
+ case 'unknown':
152
+ default:
153
+ return (
154
+ <JsonFallback
155
+ label={fieldLabel(prop)}
156
+ value={value}
157
+ onChange={onChange}
158
+ hint={fallbackHint(type)}
159
+ />
160
+ )
161
+ }
162
+ }
163
+
164
+ function fieldLabel(prop: PropSpec): React.ReactNode {
165
+ return (
166
+ <>
167
+ <span className="font-medium">{prop.name}</span>
168
+ {prop.required ? <span className="ml-0.5 text-[var(--destructive)]">*</span> : null}
169
+ </>
170
+ )
171
+ }
172
+
173
+ function fallbackHint(type: PropType): string {
174
+ if (type.kind === 'reference') {
175
+ return `Type '${type.targetType}' could not be resolved — paste a raw JSON value for now.`
176
+ }
177
+ if (type.kind === 'union') {
178
+ return 'Non-discriminated union — paste a raw JSON value (the form renders a picker for unions with a shared literal tag field like `kind`).'
179
+ }
180
+ return 'Unknown type — paste a raw JSON value.'
181
+ }
182
+
183
+ // ─── Primitive editors ───────────────────────────────────────────────
184
+
185
+ function StringInput({
186
+ label,
187
+ value,
188
+ onChange,
189
+ }: {
190
+ label: React.ReactNode
191
+ value: unknown
192
+ onChange: (v: unknown) => void
193
+ }) {
194
+ const stringVal = typeof value === 'string' ? value : ''
195
+ return (
196
+ <label className="block text-sm">
197
+ <div className="mb-1">{label}</div>
198
+ <input
199
+ type="text"
200
+ value={stringVal}
201
+ onChange={(e) => onChange(e.target.value)}
202
+ className="w-full rounded-md border border-[var(--border)] bg-[var(--input-background)] px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-[var(--ring)]"
203
+ />
204
+ </label>
205
+ )
206
+ }
207
+
208
+ function NumberInputControl({
209
+ label,
210
+ value,
211
+ onChange,
212
+ }: {
213
+ label: React.ReactNode
214
+ value: unknown
215
+ onChange: (v: unknown) => void
216
+ }) {
217
+ const numericVal = typeof value === 'number' ? value : ''
218
+ return (
219
+ <label className="block text-sm">
220
+ <div className="mb-1">{label}</div>
221
+ <input
222
+ type="number"
223
+ value={numericVal === '' ? '' : numericVal}
224
+ onChange={(e) => {
225
+ const raw = e.target.value
226
+ if (raw === '') {
227
+ onChange(undefined)
228
+ return
229
+ }
230
+ const parsed = Number(raw)
231
+ onChange(Number.isNaN(parsed) ? raw : parsed)
232
+ }}
233
+ className="w-full rounded-md border border-[var(--border)] bg-[var(--input-background)] px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-[var(--ring)]"
234
+ />
235
+ </label>
236
+ )
237
+ }
238
+
239
+ function BooleanInput({
240
+ label,
241
+ value,
242
+ onChange,
243
+ }: {
244
+ label: React.ReactNode
245
+ value: unknown
246
+ onChange: (v: unknown) => void
247
+ }) {
248
+ const boolVal = value === true
249
+ return (
250
+ <div className="flex items-center justify-between text-sm">
251
+ <span>{label}</span>
252
+ <button
253
+ type="button"
254
+ role="switch"
255
+ aria-checked={boolVal}
256
+ onClick={() => onChange(!boolVal)}
257
+ className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
258
+ boolVal ? 'bg-[var(--primary)]' : 'bg-[var(--muted)]'
259
+ }`}
260
+ >
261
+ <span
262
+ className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-sm ring-0 transition-transform ${
263
+ boolVal ? 'translate-x-5' : 'translate-x-0'
264
+ }`}
265
+ />
266
+ </button>
267
+ </div>
268
+ )
269
+ }
270
+
271
+ function EnumInput({
272
+ label,
273
+ options,
274
+ value,
275
+ onChange,
276
+ }: {
277
+ label: React.ReactNode
278
+ options: (string | number)[]
279
+ value: unknown
280
+ onChange: (v: unknown) => void
281
+ }) {
282
+ const currentIdx = options.findIndex((o) => o === value)
283
+ return (
284
+ <label className="block text-sm">
285
+ <div className="mb-1">{label}</div>
286
+ <select
287
+ value={currentIdx === -1 ? '' : String(currentIdx)}
288
+ onChange={(e) => {
289
+ onChange(parseEnumSelection(e.target.value, options))
290
+ }}
291
+ className="w-full rounded-md border border-[var(--border)] bg-[var(--input-background)] px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-[var(--ring)]"
292
+ >
293
+ <option value="">Select…</option>
294
+ {options.map((opt, idx) => (
295
+ <option key={String(opt)} value={idx}>
296
+ {String(opt)}
297
+ </option>
298
+ ))}
299
+ </select>
300
+ </label>
301
+ )
302
+ }
303
+
304
+ function LiteralDisplay({
305
+ label,
306
+ literal,
307
+ }: {
308
+ label: React.ReactNode
309
+ literal: string | number | boolean
310
+ }) {
311
+ return (
312
+ <div className="text-sm">
313
+ <div className="mb-1">{label}</div>
314
+ <div className="rounded-md border border-dashed border-[var(--border)] bg-[var(--muted)] px-3 py-2 text-xs text-[var(--muted-foreground)]">
315
+ Fixed: <code>{String(literal)}</code>
316
+ </div>
317
+ </div>
318
+ )
319
+ }
320
+
321
+ // ─── Composite editors ───────────────────────────────────────────────
322
+
323
+ function ObjectInput({
324
+ label,
325
+ fields,
326
+ value,
327
+ onChange,
328
+ errors,
329
+ path,
330
+ }: {
331
+ label: React.ReactNode
332
+ fields: PropSpec[]
333
+ value: unknown
334
+ onChange: (v: unknown) => void
335
+ errors: Record<string, string>
336
+ path: string
337
+ }) {
338
+ const obj = isPlainObject(value) ? value : {}
339
+ return (
340
+ <fieldset className="rounded-md border border-[var(--border)] bg-[var(--card)] p-3 text-sm">
341
+ <legend className="px-1 text-xs tracking-wide text-[var(--muted-foreground)] uppercase">
342
+ {label}
343
+ </legend>
344
+ <div className="space-y-3">
345
+ {fields.map((field) => (
346
+ <PropInput
347
+ key={field.name}
348
+ prop={field}
349
+ value={obj[field.name]}
350
+ onChange={(next) => onChange({ ...obj, [field.name]: next })}
351
+ errors={errors}
352
+ path={`${path}.${field.name}`}
353
+ />
354
+ ))}
355
+ </div>
356
+ </fieldset>
357
+ )
358
+ }
359
+
360
+ function ArrayInput({
361
+ label,
362
+ itemType,
363
+ itemPath,
364
+ value,
365
+ onChange,
366
+ errors,
367
+ }: {
368
+ label: React.ReactNode
369
+ itemType: PropType
370
+ itemPath: string
371
+ value: unknown
372
+ onChange: (v: unknown) => void
373
+ errors: Record<string, string>
374
+ }) {
375
+ const arr = Array.isArray(value) ? value : []
376
+ return (
377
+ <div className="text-sm">
378
+ <div className="mb-2 flex items-center justify-between">
379
+ <span>{label}</span>
380
+ <button
381
+ type="button"
382
+ onClick={() => onChange([...arr, defaultForType(itemType)])}
383
+ className="rounded-md border border-[var(--border)] bg-[var(--input-background)] px-2 py-1 text-xs text-[var(--foreground)] hover:bg-[var(--accent)]"
384
+ >
385
+ + Add
386
+ </button>
387
+ </div>
388
+ {arr.length === 0 ? (
389
+ <div className="rounded-md border border-dashed border-[var(--border)] px-3 py-4 text-center text-xs text-[var(--muted-foreground)]">
390
+ No items yet
391
+ </div>
392
+ ) : (
393
+ <ol className="space-y-2">
394
+ {arr.map((item, idx) => (
395
+ <li key={idx} className="rounded-md border border-[var(--border)] bg-[var(--card)] p-3">
396
+ <div className="mb-2 flex items-center justify-between text-xs text-[var(--muted-foreground)]">
397
+ <span>Item {idx + 1}</span>
398
+ <button
399
+ type="button"
400
+ onClick={() => onChange(arr.filter((_, i) => i !== idx))}
401
+ className="text-[var(--destructive)] hover:underline"
402
+ >
403
+ Remove
404
+ </button>
405
+ </div>
406
+ <PropInput
407
+ prop={{ name: `[${idx}]`, type: itemType, required: true }}
408
+ value={item}
409
+ onChange={(next) => onChange(arr.map((v, i) => (i === idx ? next : v)))}
410
+ errors={errors}
411
+ path={`${itemPath}[${idx}]`}
412
+ />
413
+ </li>
414
+ ))}
415
+ </ol>
416
+ )}
417
+ </div>
418
+ )
419
+ }
420
+
421
+ function DiscriminatedUnionInput({
422
+ label,
423
+ union,
424
+ value,
425
+ onChange,
426
+ errors,
427
+ path,
428
+ }: {
429
+ label: React.ReactNode
430
+ union: DiscriminatedUnion
431
+ value: unknown
432
+ onChange: (v: unknown) => void
433
+ errors: Record<string, string>
434
+ path: string
435
+ }) {
436
+ const currentVariant = findVariant(value, union)
437
+ const obj = isPlainObject(value) ? value : {}
438
+
439
+ function switchTo(variantIdx: number) {
440
+ const variant = union.variants[variantIdx]
441
+ if (!variant) return
442
+ onChange(switchUnionVariant(value, union, variant))
443
+ }
444
+
445
+ const currentIdx = currentVariant
446
+ ? union.variants.findIndex((v) => v.value === currentVariant.value)
447
+ : -1
448
+
449
+ return (
450
+ <fieldset className="rounded-md border border-[var(--border)] bg-[var(--card)] p-3 text-sm">
451
+ <legend className="px-1 text-xs tracking-wide text-[var(--muted-foreground)] uppercase">
452
+ {label}
453
+ </legend>
454
+ <div className="space-y-3">
455
+ <label className="block text-sm">
456
+ <div className="mb-1">
457
+ <span className="font-medium">{union.field}</span>
458
+ <span className="ml-0.5 text-[var(--destructive)]">*</span>
459
+ <span className="ml-2 text-xs text-[var(--muted-foreground)]">(variant)</span>
460
+ </div>
461
+ <select
462
+ value={currentIdx === -1 ? '' : String(currentIdx)}
463
+ onChange={(e) => {
464
+ const idx = Number(e.target.value)
465
+ if (Number.isInteger(idx) && idx >= 0 && idx < union.variants.length) {
466
+ switchTo(idx)
467
+ }
468
+ }}
469
+ className="w-full rounded-md border border-[var(--border)] bg-[var(--input-background)] px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-[var(--ring)]"
470
+ >
471
+ <option value="" disabled>
472
+ Select a variant…
473
+ </option>
474
+ {union.variants.map((variant, idx) => (
475
+ <option key={String(variant.value)} value={idx}>
476
+ {String(variant.value)}
477
+ </option>
478
+ ))}
479
+ </select>
480
+ </label>
481
+ {currentVariant && currentVariant.remainingFields.length > 0 ? (
482
+ <div className="space-y-3">
483
+ {currentVariant.remainingFields.map((field) => (
484
+ <PropInput
485
+ key={field.name}
486
+ prop={field}
487
+ value={obj[field.name]}
488
+ onChange={(next) => onChange({ ...obj, [field.name]: next })}
489
+ errors={errors}
490
+ path={`${path}.${field.name}`}
491
+ />
492
+ ))}
493
+ </div>
494
+ ) : null}
495
+ {!currentVariant ? (
496
+ <p className="text-xs text-[var(--muted-foreground)]">
497
+ Pick a variant to configure its fields.
498
+ </p>
499
+ ) : null}
500
+ </div>
501
+ </fieldset>
502
+ )
503
+ }
504
+
505
+ function JsonFallback({
506
+ label,
507
+ value,
508
+ onChange,
509
+ hint,
510
+ }: {
511
+ label: React.ReactNode
512
+ value: unknown
513
+ onChange: (v: unknown) => void
514
+ hint: string
515
+ }) {
516
+ // Treat both `undefined` and `null` as "empty" — `null` arrives as
517
+ // the structural default for kinds we can't seed (union / reference /
518
+ // unknown) and also from a JSON round-trip on `undefined`. Rendering
519
+ // the literal string "null" would surface as broken UX.
520
+ const text = value === undefined || value === null ? '' : safeJsonStringify(value)
521
+ return (
522
+ <label className="block text-sm">
523
+ <div className="mb-1">{label}</div>
524
+ <textarea
525
+ value={text}
526
+ onChange={(e) => {
527
+ const raw = e.target.value
528
+ if (raw.trim() === '') {
529
+ onChange(undefined)
530
+ return
531
+ }
532
+ try {
533
+ onChange(JSON.parse(raw))
534
+ } catch {
535
+ // Keep the raw string so the user can finish editing without
536
+ // losing focus; the validator will catch it on save.
537
+ onChange(raw)
538
+ }
539
+ }}
540
+ rows={3}
541
+ className="w-full rounded-md border border-[var(--border)] bg-[var(--input-background)] px-3 py-2 font-mono text-xs outline-none focus:ring-2 focus:ring-[var(--ring)]"
542
+ />
543
+ <p className="mt-1 text-xs text-[var(--muted-foreground)]">{hint}</p>
544
+ </label>
545
+ )
546
+ }
547
+
548
+ // ─── Helpers ─────────────────────────────────────────────────────────
549
+
550
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
551
+ return v !== null && typeof v === 'object' && !Array.isArray(v)
552
+ }
@@ -158,12 +158,12 @@ export function RelationshipField({
158
158
  onClick={() => handleToggle(opt.id)}
159
159
  className={`flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-[var(--accent)] ${isSelected ? 'font-medium' : ''}`}
160
160
  >
161
- <div className="flex-1 min-w-0 text-left">
161
+ <div className="min-w-0 flex-1 text-left">
162
162
  <div className="flex items-center gap-2">
163
163
  <span className="truncate">{getDocTitle(opt)}</span>
164
164
  {isSelected && <span className="shrink-0 text-[var(--primary)]">&#10003;</span>}
165
165
  </div>
166
- <div className="flex items-center gap-2 mt-0.5">
166
+ <div className="mt-0.5 flex items-center gap-2">
167
167
  <span className="text-[10px] text-[var(--muted-foreground)]">{collection}</span>
168
168
  {opt.updatedAt && (
169
169
  <span className="text-[10px] text-[var(--muted-foreground)]">
@@ -201,7 +201,7 @@ export function RelationshipField({
201
201
  onClick={() => handleRemove(item.id)}
202
202
  className="hover:text-[var(--destructive)]"
203
203
  >
204
- <X className="w-3 h-3" />
204
+ <X className="h-3 w-3" />
205
205
  </button>
206
206
  </span>
207
207
  ))}
@@ -209,26 +209,26 @@ export function RelationshipField({
209
209
  )}
210
210
 
211
211
  <div className="relative">
212
- <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--muted-foreground)] pointer-events-none" />
212
+ <Search className="pointer-events-none absolute top-1/2 left-2.5 h-4 w-4 -translate-y-1/2 text-[var(--muted-foreground)]" />
213
213
  <input
214
214
  type="text"
215
215
  value={searchTerm}
216
216
  onChange={(e) => handleSearch(e.target.value)}
217
217
  onFocus={() => setOpen(true)}
218
218
  placeholder={`Search ${relationTo}...`}
219
- className="w-full rounded-md border border-[var(--border)] bg-[var(--input-background)] pl-8 pr-3 py-2 text-sm outline-none focus:ring-2 focus:ring-[var(--ring)]"
219
+ className="w-full rounded-md border border-[var(--border)] bg-[var(--input-background)] py-2 pr-3 pl-8 text-sm outline-none focus:ring-2 focus:ring-[var(--ring)]"
220
220
  />
221
221
  {loading && (
222
- <Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-[var(--muted-foreground)]" />
222
+ <Loader2 className="absolute top-1/2 right-2.5 h-4 w-4 -translate-y-1/2 animate-spin text-[var(--muted-foreground)]" />
223
223
  )}
224
224
 
225
225
  {open && (
226
- <ul className="absolute z-50 mt-1 w-full max-h-60 overflow-y-auto rounded-md border border-[var(--border)] bg-[var(--popover)] py-1 shadow-lg">
226
+ <ul className="absolute z-50 mt-1 max-h-60 w-full overflow-y-auto rounded-md border border-[var(--border)] bg-[var(--popover)] py-1 shadow-lg">
227
227
  {unselectedItems.map((opt) => (
228
228
  <li key={opt.id}>{renderOption(opt, false)}</li>
229
229
  ))}
230
230
  {selectedItems.length > 0 && unselectedItems.length > 0 && (
231
- <li className="border-t border-[var(--border)] my-1" />
231
+ <li className="my-1 border-t border-[var(--border)]" />
232
232
  )}
233
233
  {selectedItems.map((opt) => (
234
234
  <li key={opt.id}>{renderOption(opt, true)}</li>
@@ -236,7 +236,7 @@ export function RelationshipField({
236
236
  {options.length === 0 && !loading && (
237
237
  <li className="px-3 py-2 text-sm text-[var(--muted-foreground)]">No results</li>
238
238
  )}
239
- <li className="border-t border-[var(--border)] mt-1">
239
+ <li className="mt-1 border-t border-[var(--border)]">
240
240
  <button
241
241
  type="button"
242
242
  onClick={() => {
@@ -245,7 +245,7 @@ export function RelationshipField({
245
245
  }}
246
246
  className="flex w-full items-center gap-2 px-3 py-2 text-sm text-[var(--primary)] hover:bg-[var(--accent)]"
247
247
  >
248
- <Plus className="w-3.5 h-3.5" />
248
+ <Plus className="h-3.5 w-3.5" />
249
249
  Create New
250
250
  </button>
251
251
  </li>
@@ -43,7 +43,7 @@ export function TextField({
43
43
  onChange={(e) => onChange(e.target.value)}
44
44
  required={required}
45
45
  maxLength={maxLength}
46
- className={`w-full rounded-md border bg-[var(--input-background)] px-3 py-2 text-sm outline-none transition-colors focus:ring-2 focus:ring-[var(--ring)] ${
46
+ className={`w-full rounded-md border bg-[var(--input-background)] px-3 py-2 text-sm transition-colors outline-none focus:ring-2 focus:ring-[var(--ring)] ${
47
47
  hasError ? 'border-[var(--destructive)]' : 'border-[var(--border)]'
48
48
  }`}
49
49
  />