@pilotiq/pilotiq 0.23.1 → 0.24.2

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 (500) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/boost/guidelines.md +566 -0
  3. package/boost/skills/pilotiq-fields/SKILL.md +47 -0
  4. package/boost/skills/pilotiq-fields/rules/field-catalog.md +288 -0
  5. package/boost/skills/pilotiq-fields/rules/reactive-fields.md +199 -0
  6. package/boost/skills/pilotiq-fields/rules/validation.md +198 -0
  7. package/boost/skills/pilotiq-relations/SKILL.md +47 -0
  8. package/boost/skills/pilotiq-relations/rules/relation-managers.md +256 -0
  9. package/boost/skills/pilotiq-relations/rules/repeater-relationship.md +177 -0
  10. package/boost/skills/pilotiq-resource/SKILL.md +61 -0
  11. package/boost/skills/pilotiq-resource/rules/authorization.md +242 -0
  12. package/boost/skills/pilotiq-resource/rules/defining-resources.md +228 -0
  13. package/boost/skills/pilotiq-resource/rules/page-overrides.md +296 -0
  14. package/dist/actions/exportFactory.d.ts +10 -0
  15. package/dist/actions/exportFactory.d.ts.map +1 -1
  16. package/dist/actions/exportFactory.js +10 -0
  17. package/dist/actions/exportFactory.js.map +1 -1
  18. package/dist/react/CollabRoomContext.d.ts +5 -5
  19. package/dist/react/index.d.ts +0 -1
  20. package/dist/react/index.d.ts.map +1 -1
  21. package/dist/react/index.js +0 -1
  22. package/dist/react/index.js.map +1 -1
  23. package/dist/routes/helpers.d.ts.map +1 -1
  24. package/dist/routes/helpers.js +6 -2
  25. package/dist/routes/helpers.js.map +1 -1
  26. package/dist/routes/relations.d.ts.map +1 -1
  27. package/dist/routes/relations.js +12 -0
  28. package/dist/routes/relations.js.map +1 -1
  29. package/package.json +6 -1
  30. package/.turbo/turbo-build.log +0 -8
  31. package/CLAUDE.md +0 -265
  32. package/dist/react/useCollabSeed.d.ts +0 -23
  33. package/dist/react/useCollabSeed.d.ts.map +0 -1
  34. package/dist/react/useCollabSeed.js +0 -82
  35. package/dist/react/useCollabSeed.js.map +0 -1
  36. package/src/Cluster.test.ts +0 -283
  37. package/src/Cluster.ts +0 -83
  38. package/src/Column.test.ts +0 -199
  39. package/src/Column.ts +0 -710
  40. package/src/Global.test.ts +0 -367
  41. package/src/Global.ts +0 -169
  42. package/src/Page.test.ts +0 -114
  43. package/src/Page.ts +0 -208
  44. package/src/Pilotiq.perf.test.ts +0 -252
  45. package/src/Pilotiq.test.ts +0 -129
  46. package/src/Pilotiq.ts +0 -1158
  47. package/src/PilotiqRegistry.ts +0 -36
  48. package/src/PilotiqServiceProvider.ts +0 -121
  49. package/src/RelationManager.test.ts +0 -400
  50. package/src/RelationManager.ts +0 -527
  51. package/src/RenderHook.test.ts +0 -252
  52. package/src/RenderHook.ts +0 -242
  53. package/src/Resource.test.ts +0 -284
  54. package/src/Resource.ts +0 -526
  55. package/src/RightPanel.test.ts +0 -202
  56. package/src/RightPanel.ts +0 -132
  57. package/src/Tab.test.ts +0 -91
  58. package/src/Tab.ts +0 -156
  59. package/src/UserMenuItem.ts +0 -145
  60. package/src/actions/Action.test.ts +0 -2526
  61. package/src/actions/Action.ts +0 -1515
  62. package/src/actions/ActionGroup.test.ts +0 -112
  63. package/src/actions/ActionGroup.ts +0 -173
  64. package/src/actions/attachFactory.ts +0 -172
  65. package/src/actions/bulkFactories.ts +0 -168
  66. package/src/actions/crudFactories.ts +0 -220
  67. package/src/actions/exportFactory.ts +0 -215
  68. package/src/actions/factoryHelpers.ts +0 -177
  69. package/src/actions/importFactory.ts +0 -243
  70. package/src/actions/index.ts +0 -17
  71. package/src/actions/m2mFactories.ts +0 -193
  72. package/src/actions/relationFactories.ts +0 -372
  73. package/src/applyPageHooks.test.ts +0 -463
  74. package/src/applyPageHooks.ts +0 -330
  75. package/src/authorization.test.ts +0 -483
  76. package/src/breadcrumbs.test.ts +0 -238
  77. package/src/cells/coerce.test.ts +0 -85
  78. package/src/cells/coerce.ts +0 -84
  79. package/src/clusterPaths.ts +0 -35
  80. package/src/columns/BadgeColumn.test.ts +0 -54
  81. package/src/columns/BadgeColumn.ts +0 -32
  82. package/src/columns/BooleanColumn.test.ts +0 -41
  83. package/src/columns/BooleanColumn.ts +0 -18
  84. package/src/columns/ColorColumn.test.ts +0 -37
  85. package/src/columns/ColorColumn.ts +0 -38
  86. package/src/columns/IconColumn.test.ts +0 -54
  87. package/src/columns/IconColumn.ts +0 -37
  88. package/src/columns/ImageColumn.test.ts +0 -41
  89. package/src/columns/ImageColumn.ts +0 -28
  90. package/src/columns/SelectColumn.ts +0 -98
  91. package/src/columns/TextColumn.test.ts +0 -190
  92. package/src/columns/TextColumn.ts +0 -20
  93. package/src/columns/TextInputColumn.ts +0 -68
  94. package/src/columns/ToggleColumn.ts +0 -46
  95. package/src/columns/editableColumns.test.ts +0 -238
  96. package/src/columns/index.ts +0 -9
  97. package/src/defaultGlobalPages.ts +0 -95
  98. package/src/defaultPages.test.ts +0 -634
  99. package/src/defaultPages.ts +0 -617
  100. package/src/defaultViewPage.test.ts +0 -147
  101. package/src/elements/Form.test.ts +0 -223
  102. package/src/elements/Form.ts +0 -416
  103. package/src/elements/ListTabs.ts +0 -28
  104. package/src/elements/Table.test.ts +0 -422
  105. package/src/elements/Table.ts +0 -850
  106. package/src/elements/TableGroup.test.ts +0 -260
  107. package/src/elements/TableGroup.ts +0 -334
  108. package/src/elements/dispatchAction.test.ts +0 -463
  109. package/src/elements/dispatchAction.ts +0 -355
  110. package/src/elements/dispatchForm.test.ts +0 -477
  111. package/src/elements/dispatchForm.ts +0 -1993
  112. package/src/elements/dispatchTable.test.ts +0 -1514
  113. package/src/elements/dispatchTable.ts +0 -745
  114. package/src/elements/index.ts +0 -21
  115. package/src/entries/BadgeEntry.ts +0 -39
  116. package/src/entries/CodeEntry.test.ts +0 -40
  117. package/src/entries/CodeEntry.ts +0 -52
  118. package/src/entries/ColorEntry.ts +0 -63
  119. package/src/entries/ComponentEntry.test.ts +0 -173
  120. package/src/entries/ComponentEntry.ts +0 -95
  121. package/src/entries/Entry.ts +0 -304
  122. package/src/entries/IconEntry.ts +0 -49
  123. package/src/entries/ImageEntry.ts +0 -61
  124. package/src/entries/KeyValueEntry.ts +0 -47
  125. package/src/entries/RepeatableEntry.test.ts +0 -239
  126. package/src/entries/RepeatableEntry.ts +0 -173
  127. package/src/entries/TextEntry.test.ts +0 -394
  128. package/src/entries/TextEntry.ts +0 -60
  129. package/src/entries/index.ts +0 -12
  130. package/src/entries/leaves.test.ts +0 -306
  131. package/src/entries/registry.ts +0 -54
  132. package/src/fields/BuilderField.test.ts +0 -1188
  133. package/src/fields/BuilderField.ts +0 -605
  134. package/src/fields/BuilderRelationship.test.ts +0 -811
  135. package/src/fields/CheckboxField.test.ts +0 -44
  136. package/src/fields/CheckboxField.ts +0 -27
  137. package/src/fields/CheckboxListField.test.ts +0 -99
  138. package/src/fields/CheckboxListField.ts +0 -66
  139. package/src/fields/ColorPickerField.test.ts +0 -33
  140. package/src/fields/ColorPickerField.ts +0 -25
  141. package/src/fields/DateField.ts +0 -54
  142. package/src/fields/DateTimeField.test.ts +0 -55
  143. package/src/fields/EmailField.ts +0 -16
  144. package/src/fields/Field.test.ts +0 -654
  145. package/src/fields/Field.ts +0 -817
  146. package/src/fields/FileUploadField.test.ts +0 -143
  147. package/src/fields/FileUploadField.ts +0 -159
  148. package/src/fields/HiddenField.test.ts +0 -27
  149. package/src/fields/HiddenField.ts +0 -28
  150. package/src/fields/KeyValueField.test.ts +0 -105
  151. package/src/fields/KeyValueField.ts +0 -55
  152. package/src/fields/MarkdownField.test.ts +0 -167
  153. package/src/fields/MarkdownField.ts +0 -162
  154. package/src/fields/NumberField.ts +0 -33
  155. package/src/fields/RadioField.test.ts +0 -94
  156. package/src/fields/RadioField.ts +0 -67
  157. package/src/fields/RepeaterField.test.ts +0 -1806
  158. package/src/fields/RepeaterField.ts +0 -939
  159. package/src/fields/RepeaterRelationship.test.ts +0 -1923
  160. package/src/fields/RepeaterSimple.test.ts +0 -248
  161. package/src/fields/RowButton.test.ts +0 -219
  162. package/src/fields/RowButton.ts +0 -135
  163. package/src/fields/SelectField.test.ts +0 -192
  164. package/src/fields/SelectField.ts +0 -235
  165. package/src/fields/SliderField.test.ts +0 -50
  166. package/src/fields/SliderField.ts +0 -53
  167. package/src/fields/SlugField.ts +0 -24
  168. package/src/fields/TagsInputField.test.ts +0 -154
  169. package/src/fields/TagsInputField.ts +0 -133
  170. package/src/fields/TextField.test.ts +0 -213
  171. package/src/fields/TextField.ts +0 -177
  172. package/src/fields/TextareaField.test.ts +0 -58
  173. package/src/fields/TextareaField.ts +0 -59
  174. package/src/fields/ToggleButtonsField.test.ts +0 -106
  175. package/src/fields/ToggleButtonsField.ts +0 -59
  176. package/src/fields/ToggleField.ts +0 -16
  177. package/src/fields/disableOptionsWhenSelectedInSiblingRepeaterItems.test.ts +0 -319
  178. package/src/fields/optionsResolver.ts +0 -95
  179. package/src/fields/resolveField.ts +0 -28
  180. package/src/filters/BooleanFilter.ts +0 -35
  181. package/src/filters/DateRangeFilter.test.ts +0 -194
  182. package/src/filters/DateRangeFilter.ts +0 -148
  183. package/src/filters/Filter.test.ts +0 -268
  184. package/src/filters/Filter.ts +0 -184
  185. package/src/filters/FormFilter.test.ts +0 -238
  186. package/src/filters/FormFilter.ts +0 -215
  187. package/src/filters/MultiSelectFilter.test.ts +0 -119
  188. package/src/filters/MultiSelectFilter.ts +0 -78
  189. package/src/filters/QueryBuilderFilter.test.ts +0 -662
  190. package/src/filters/QueryBuilderFilter.ts +0 -398
  191. package/src/filters/SelectFilter.ts +0 -46
  192. package/src/filters/TernaryFilter.test.ts +0 -160
  193. package/src/filters/TernaryFilter.ts +0 -72
  194. package/src/filters/TrashedFilter.test.ts +0 -149
  195. package/src/filters/TrashedFilter.ts +0 -55
  196. package/src/filters/queryBuilder/BooleanConstraint.ts +0 -31
  197. package/src/filters/queryBuilder/Constraint.ts +0 -115
  198. package/src/filters/queryBuilder/DateConstraint.ts +0 -69
  199. package/src/filters/queryBuilder/NumberConstraint.ts +0 -66
  200. package/src/filters/queryBuilder/SelectConstraint.ts +0 -72
  201. package/src/filters/queryBuilder/TextConstraint.ts +0 -64
  202. package/src/filters/queryBuilder/index.ts +0 -12
  203. package/src/icons/index.ts +0 -2
  204. package/src/icons/lucide.ts +0 -204
  205. package/src/icons/registry.test.ts +0 -56
  206. package/src/icons/registry.ts +0 -41
  207. package/src/icons/types.ts +0 -47
  208. package/src/index.ts +0 -525
  209. package/src/io/csv.test.ts +0 -142
  210. package/src/io/csv.ts +0 -170
  211. package/src/nestedRelationManagerData.test.ts +0 -547
  212. package/src/notifications/Notification.test.ts +0 -210
  213. package/src/notifications/Notification.ts +0 -354
  214. package/src/notifications/broadcast.test.ts +0 -110
  215. package/src/notifications/broadcast.ts +0 -95
  216. package/src/notifications/database.test.ts +0 -383
  217. package/src/notifications/database.ts +0 -398
  218. package/src/notifications/databaseNotifications.test.ts +0 -187
  219. package/src/notifications/dispatchNotificationAction.test.ts +0 -341
  220. package/src/notifications/dispatchNotificationAction.ts +0 -142
  221. package/src/notifications/flash.test.ts +0 -89
  222. package/src/notifications/flash.ts +0 -71
  223. package/src/notifications/index.ts +0 -45
  224. package/src/notifications/registerBroadcastAuth.test.ts +0 -134
  225. package/src/notifications/registerBroadcastAuth.ts +0 -100
  226. package/src/notifications/resolveSavedNotification.test.ts +0 -82
  227. package/src/notifications/resolveSavedNotification.ts +0 -59
  228. package/src/notifications/types.ts +0 -93
  229. package/src/orm/m2mAccessor.ts +0 -66
  230. package/src/orm/modelDefaults.test.ts +0 -633
  231. package/src/orm/modelDefaults.ts +0 -666
  232. package/src/pageData/breadcrumbs.ts +0 -288
  233. package/src/pageData/forms.ts +0 -578
  234. package/src/pageData/helpers.ts +0 -857
  235. package/src/pageData/misc.ts +0 -347
  236. package/src/pageData/navigation.ts +0 -842
  237. package/src/pageData/relationPages.ts +0 -1248
  238. package/src/pageData/relationTabs.ts +0 -286
  239. package/src/pageData/resourcePages.ts +0 -609
  240. package/src/pageData.test.ts +0 -1545
  241. package/src/pageData.ts +0 -341
  242. package/src/plugins/index.ts +0 -8
  243. package/src/plugins/themeEditor.test.ts +0 -36
  244. package/src/plugins/themeEditor.ts +0 -45
  245. package/src/react/AppShell.tsx +0 -251
  246. package/src/react/CollabExtensionFactoryRegistry.ts +0 -55
  247. package/src/react/CollabRoomContext.ts +0 -98
  248. package/src/react/CollabTextRendererRegistry.ts +0 -102
  249. package/src/react/CommandPalette.tsx +0 -375
  250. package/src/react/CurrentUserContext.tsx +0 -50
  251. package/src/react/CustomPageWrapperGate.tsx +0 -69
  252. package/src/react/CustomPageWrapperRegistry.ts +0 -45
  253. package/src/react/FieldFocusReporterRegistry.ts +0 -37
  254. package/src/react/FieldLabelSlotRegistry.ts +0 -30
  255. package/src/react/FieldPresenceRegistry.ts +0 -46
  256. package/src/react/FormCollabBindingRegistry.ts +0 -242
  257. package/src/react/FormStateContext.tsx +0 -591
  258. package/src/react/HeadHooks.tsx +0 -126
  259. package/src/react/MarkdownEditorRegistry.test.ts +0 -38
  260. package/src/react/MarkdownEditorRegistry.ts +0 -107
  261. package/src/react/NotificationActionStrip.tsx +0 -263
  262. package/src/react/NotificationBell.tsx +0 -426
  263. package/src/react/PendingSuggestionApplierRegistry.test.ts +0 -97
  264. package/src/react/PendingSuggestionApplierRegistry.ts +0 -98
  265. package/src/react/PendingSuggestionOverlayRegistry.ts +0 -54
  266. package/src/react/PendingSuggestionsContext.tsx +0 -172
  267. package/src/react/RecordWrapperGate.tsx +0 -58
  268. package/src/react/RecordWrapperRegistry.ts +0 -39
  269. package/src/react/RenderHookSlot.tsx +0 -32
  270. package/src/react/RightSidebar.tsx +0 -257
  271. package/src/react/RightSidebarContext.tsx +0 -234
  272. package/src/react/RightSidebarTrigger.tsx +0 -53
  273. package/src/react/RowCoordsContext.tsx +0 -23
  274. package/src/react/SchemaRenderer.tsx +0 -549
  275. package/src/react/SearchTrigger.tsx +0 -46
  276. package/src/react/ThemeProvider.tsx +0 -93
  277. package/src/react/ThemeSettingsPage.tsx +0 -579
  278. package/src/react/ThemeToggle.tsx +0 -20
  279. package/src/react/Toaster.tsx +0 -158
  280. package/src/react/UserMenu.tsx +0 -196
  281. package/src/react/WidgetDataContext.tsx +0 -157
  282. package/src/react/cells/EditableCell.tsx +0 -389
  283. package/src/react/component-slots.test.ts +0 -103
  284. package/src/react/component-slots.ts +0 -116
  285. package/src/react/fieldJsHandler.test.ts +0 -166
  286. package/src/react/fieldJsHandler.ts +0 -79
  287. package/src/react/fields/BuilderInput.tsx +0 -1078
  288. package/src/react/fields/CheckboxInput.tsx +0 -39
  289. package/src/react/fields/CheckboxListInput.tsx +0 -102
  290. package/src/react/fields/ColorInput.tsx +0 -71
  291. package/src/react/fields/DateFieldInput.tsx +0 -70
  292. package/src/react/fields/DateTimeInput.tsx +0 -62
  293. package/src/react/fields/FieldShell.tsx +0 -348
  294. package/src/react/fields/FileUploadInput.tsx +0 -639
  295. package/src/react/fields/HiddenInput.tsx +0 -17
  296. package/src/react/fields/KeyValueInput.tsx +0 -230
  297. package/src/react/fields/MarkdownInput.tsx +0 -560
  298. package/src/react/fields/RadioInput.tsx +0 -81
  299. package/src/react/fields/RepeaterInput.test.ts +0 -116
  300. package/src/react/fields/RepeaterInput.tsx +0 -1420
  301. package/src/react/fields/SelectFieldInput.tsx +0 -280
  302. package/src/react/fields/SliderInput.tsx +0 -81
  303. package/src/react/fields/TagsInput.tsx +0 -283
  304. package/src/react/fields/TextLikeInput.tsx +0 -256
  305. package/src/react/fields/ToggleButtonsInput.tsx +0 -60
  306. package/src/react/fields/ToggleFieldInput.tsx +0 -56
  307. package/src/react/fields/relationshipRenameDispatch.test.ts +0 -106
  308. package/src/react/fields/relationshipRenameDispatch.ts +0 -97
  309. package/src/react/fields/repeaterReconcile.test.ts +0 -114
  310. package/src/react/fields/repeaterReconcile.ts +0 -104
  311. package/src/react/fields/rowChromeButton.tsx +0 -336
  312. package/src/react/fields/rowState.ts +0 -106
  313. package/src/react/fields/syncRowGates.test.ts +0 -202
  314. package/src/react/fields/syncRowGates.ts +0 -66
  315. package/src/react/fields/textInputControls.tsx +0 -238
  316. package/src/react/fields/useRowReorderDnd.ts +0 -78
  317. package/src/react/formStateHelpers.test.ts +0 -508
  318. package/src/react/formStateHelpers.ts +0 -381
  319. package/src/react/hooks/use-mobile.ts +0 -19
  320. package/src/react/icon-context.tsx +0 -60
  321. package/src/react/index.ts +0 -195
  322. package/src/react/layouts/SidebarLayout.tsx +0 -250
  323. package/src/react/layouts/TopbarLayout.tsx +0 -258
  324. package/src/react/navigate.tsx +0 -37
  325. package/src/react/onProviderSynced.test.ts +0 -90
  326. package/src/react/parseRecordEditUrl.test.ts +0 -122
  327. package/src/react/parseRecordEditUrl.ts +0 -94
  328. package/src/react/persistedState.ts +0 -40
  329. package/src/react/registry.ts +0 -48
  330. package/src/react/right-panel-registry.tsx +0 -47
  331. package/src/react/schemaRenderer/AlertRenderer.tsx +0 -112
  332. package/src/react/schemaRenderer/EntryRenderer.tsx +0 -501
  333. package/src/react/schemaRenderer/SectionRenderer.tsx +0 -120
  334. package/src/react/schemaRenderer/SimpleElements.tsx +0 -306
  335. package/src/react/schemaRenderer/TabsRenderer.tsx +0 -62
  336. package/src/react/schemaRenderer/WizardRenderer.tsx +0 -338
  337. package/src/react/schemaRenderer/action/ActionGroupTrigger.tsx +0 -177
  338. package/src/react/schemaRenderer/action/ActionModalDialog.tsx +0 -273
  339. package/src/react/schemaRenderer/action/ConfirmActionDialog.tsx +0 -61
  340. package/src/react/schemaRenderer/action/HandlerActionButton.tsx +0 -43
  341. package/src/react/schemaRenderer/action/MethodActionButton.tsx +0 -64
  342. package/src/react/schemaRenderer/action/buttons.tsx +0 -99
  343. package/src/react/schemaRenderer/action/helpers.ts +0 -140
  344. package/src/react/schemaRenderer/action/renderAction.tsx +0 -245
  345. package/src/react/schemaRenderer/columnFormat.ts +0 -65
  346. package/src/react/schemaRenderer/constants.ts +0 -50
  347. package/src/react/schemaRenderer/form/FormRenderer.tsx +0 -274
  348. package/src/react/schemaRenderer/form/renderField.tsx +0 -511
  349. package/src/react/schemaRenderer/helpers.tsx +0 -81
  350. package/src/react/schemaRenderer/table/CardsLayoutBody.tsx +0 -308
  351. package/src/react/schemaRenderer/table/TableRenderer.tsx +0 -123
  352. package/src/react/schemaRenderer/table/TableRendererBody.tsx +0 -974
  353. package/src/react/schemaRenderer/table/filters.tsx +0 -1233
  354. package/src/react/schemaRenderer/table/formatCell.tsx +0 -264
  355. package/src/react/schemaRenderer/table/links.tsx +0 -112
  356. package/src/react/schemaRenderer/table/renderRowActions.tsx +0 -52
  357. package/src/react/schemaRenderer/table/url.tsx +0 -143
  358. package/src/react/theme-preview/apply.ts +0 -99
  359. package/src/react/theme-preview/build-html.ts +0 -436
  360. package/src/react/ui/button.tsx +0 -51
  361. package/src/react/ui/calendar.tsx +0 -67
  362. package/src/react/ui/checkbox.tsx +0 -29
  363. package/src/react/ui/dialog.tsx +0 -108
  364. package/src/react/ui/dropdown-menu.tsx +0 -97
  365. package/src/react/ui/input.tsx +0 -20
  366. package/src/react/ui/label.tsx +0 -21
  367. package/src/react/ui/popover.tsx +0 -50
  368. package/src/react/ui/select.tsx +0 -169
  369. package/src/react/ui/separator.tsx +0 -25
  370. package/src/react/ui/sheet.tsx +0 -136
  371. package/src/react/ui/sidebar.tsx +0 -723
  372. package/src/react/ui/skeleton.tsx +0 -13
  373. package/src/react/ui/slider.tsx +0 -34
  374. package/src/react/ui/switch.tsx +0 -28
  375. package/src/react/ui/table.tsx +0 -105
  376. package/src/react/ui/tabs.tsx +0 -63
  377. package/src/react/ui/textarea.tsx +0 -18
  378. package/src/react/ui/tooltip.tsx +0 -64
  379. package/src/react/useCollabSeed.ts +0 -86
  380. package/src/react/useResizableWidth.ts +0 -139
  381. package/src/react/utils.ts +0 -6
  382. package/src/react/widgetRegistry.test.ts +0 -43
  383. package/src/react/widgetRegistry.ts +0 -50
  384. package/src/react/widgets/StatsOverviewRenderer.tsx +0 -232
  385. package/src/react/widgets/TableWidgetRenderer.tsx +0 -231
  386. package/src/react/widgets/ViewRenderer.tsx +0 -71
  387. package/src/relationManagerData.test.ts +0 -1595
  388. package/src/richtext/index.ts +0 -8
  389. package/src/richtext/registry.ts +0 -89
  390. package/src/routes/globals.ts +0 -148
  391. package/src/routes/guard.test.ts +0 -325
  392. package/src/routes/helpers.ts +0 -700
  393. package/src/routes/pages.ts +0 -175
  394. package/src/routes/panel.ts +0 -204
  395. package/src/routes/relations.ts +0 -1227
  396. package/src/routes/resources.ts +0 -781
  397. package/src/routes/theme.ts +0 -91
  398. package/src/routes-nested-relations.test.ts +0 -676
  399. package/src/routes-relations.test.ts +0 -972
  400. package/src/routes.test.ts +0 -2027
  401. package/src/routes.ts +0 -303
  402. package/src/schema/Alert.test.ts +0 -109
  403. package/src/schema/Alert.ts +0 -131
  404. package/src/schema/Block.ts +0 -169
  405. package/src/schema/Breadcrumbs.ts +0 -40
  406. package/src/schema/Card.ts +0 -35
  407. package/src/schema/Divider.ts +0 -20
  408. package/src/schema/Element.ts +0 -219
  409. package/src/schema/EmptyState.test.ts +0 -37
  410. package/src/schema/EmptyState.ts +0 -63
  411. package/src/schema/Fieldset.ts +0 -43
  412. package/src/schema/Grid.ts +0 -43
  413. package/src/schema/Group.ts +0 -30
  414. package/src/schema/Heading.ts +0 -39
  415. package/src/schema/Html.ts +0 -67
  416. package/src/schema/Icon.ts +0 -54
  417. package/src/schema/Image.ts +0 -57
  418. package/src/schema/LinkTag.ts +0 -41
  419. package/src/schema/Markdown.ts +0 -85
  420. package/src/schema/MetaTag.ts +0 -41
  421. package/src/schema/RelationTabs.ts +0 -71
  422. package/src/schema/ScriptTag.ts +0 -55
  423. package/src/schema/Section.ts +0 -160
  424. package/src/schema/ServerDataElement.test.ts +0 -140
  425. package/src/schema/ServerDataElement.ts +0 -156
  426. package/src/schema/SlotComponent.test.ts +0 -77
  427. package/src/schema/SlotComponent.ts +0 -71
  428. package/src/schema/Split.ts +0 -50
  429. package/src/schema/Stat.test.ts +0 -118
  430. package/src/schema/Stat.ts +0 -154
  431. package/src/schema/StatsOverview.test.ts +0 -141
  432. package/src/schema/StatsOverview.ts +0 -119
  433. package/src/schema/StyleTag.ts +0 -35
  434. package/src/schema/TableWidget.test.ts +0 -297
  435. package/src/schema/TableWidget.ts +0 -289
  436. package/src/schema/Tabs.ts +0 -79
  437. package/src/schema/Text.ts +0 -58
  438. package/src/schema/UnorderedList.ts +0 -49
  439. package/src/schema/View.test.ts +0 -111
  440. package/src/schema/View.ts +0 -127
  441. package/src/schema/Wizard.ts +0 -220
  442. package/src/schema/containers.test.ts +0 -564
  443. package/src/schema/headTags.test.ts +0 -134
  444. package/src/schema/index.ts +0 -40
  445. package/src/schema/primes.test.ts +0 -269
  446. package/src/schema/resolveSchema.test.ts +0 -379
  447. package/src/schema/resolveSchema.ts +0 -917
  448. package/src/schema/sanitize.ts +0 -58
  449. package/src/search.test.ts +0 -446
  450. package/src/search.ts +0 -178
  451. package/src/sessionFilters.test.ts +0 -375
  452. package/src/sessionFilters.ts +0 -143
  453. package/src/slot-components/index.ts +0 -10
  454. package/src/slot-components/registry.ts +0 -56
  455. package/src/styles/file-upload.css +0 -13
  456. package/src/summarizers/Summarizer.test.ts +0 -84
  457. package/src/summarizers/Summarizer.ts +0 -123
  458. package/src/summarizers/index.ts +0 -11
  459. package/src/theme/base-colors.ts +0 -68
  460. package/src/theme/chart-colors.ts +0 -50
  461. package/src/theme/colors.ts +0 -447
  462. package/src/theme/generate-css.test.ts +0 -139
  463. package/src/theme/generate-css.ts +0 -44
  464. package/src/theme/generate-scale.test.ts +0 -106
  465. package/src/theme/generate-scale.ts +0 -97
  466. package/src/theme/icon-map.ts +0 -42
  467. package/src/theme/index.ts +0 -34
  468. package/src/theme/migrate.test.ts +0 -178
  469. package/src/theme/migrate.ts +0 -81
  470. package/src/theme/presets.ts +0 -135
  471. package/src/theme/radius.ts +0 -18
  472. package/src/theme/resolve.test.ts +0 -238
  473. package/src/theme/resolve.ts +0 -96
  474. package/src/theme/spacing.ts +0 -18
  475. package/src/theme/storage.test.ts +0 -126
  476. package/src/theme/storage.ts +0 -106
  477. package/src/theme/theme-colors.ts +0 -88
  478. package/src/theme/types.ts +0 -125
  479. package/src/uploads/UploadAdapter.ts +0 -35
  480. package/src/uploads/index.ts +0 -2
  481. package/src/uploads/localUpload.test.ts +0 -70
  482. package/src/uploads/localUpload.ts +0 -84
  483. package/src/validation/Validator.ts +0 -49
  484. package/src/validation/index.ts +0 -28
  485. package/src/validation/rules.ts +0 -78
  486. package/src/validation/runValidators.ts +0 -435
  487. package/src/validation/uniqueValidator.test.ts +0 -196
  488. package/src/validation/uniqueValidator.ts +0 -133
  489. package/src/validation/validators.test.ts +0 -268
  490. package/src/vite.test.ts +0 -184
  491. package/src/vite.ts +0 -787
  492. package/src/widgets/index.ts +0 -10
  493. package/src/widgets/registry.ts +0 -45
  494. package/src/widgets.test.ts +0 -592
  495. package/tsconfig.build.json +0 -11
  496. package/tsconfig.json +0 -4
  497. package/tsconfig.test.json +0 -10
  498. package/views/react/Dashboard.tsx +0 -27
  499. package/views/react/Resources/Form.tsx +0 -102
  500. package/views/react/Resources/Index.tsx +0 -49
package/CLAUDE.md DELETED
@@ -1,265 +0,0 @@
1
- # CLAUDE.md — `@pilotiq/pilotiq`
2
-
3
- Deep notes for the **new** view-based admin panel package. The root `CLAUDE.md` covers monorepo-wide concerns (commands, playgrounds, cross-repo wiring); this file is loaded automatically when working inside `packages/pilotiq/`.
4
-
5
- ---
6
-
7
- ## Architecture
8
-
9
- ```
10
- Pilotiq.make() builder → pilotiq([panels]) provider → registerPilotiqRoutes() → @rudderjs/view
11
- pilotiq() Vite plugin → auto-generates pages/(pilotiq)/ stubs
12
- ```
13
-
14
- **Setup (two lines):**
15
- 1. `vite.config.ts` — `import { pilotiq } from '@pilotiq/pilotiq/vite'` → `plugins: [pilotiq(), ...]`
16
- 2. `bootstrap/providers.ts` — `import { pilotiq } from '@pilotiq/pilotiq'` → `pilotiq([adminPanel])`
17
-
18
- **Extending the panel:** custom Field / Column / Entry / Widget primitives are documented end-to-end in `docs/guide/extending-pilotiq.md`. Field renderers register via `registerFieldRenderer` (`@pilotiq/pilotiq/react`); ComponentEntry components via `registerEntryComponents` (`@pilotiq/pilotiq/entries`); View components via `registerWidgetComponents` (`@pilotiq/pilotiq/widgets`); whole new widget element types via `registerWidgetRenderer` (`@pilotiq/pilotiq/react`); field label slot (inject UI next to any field label from a plugin) via `registerFieldLabelSlot` (`@pilotiq/pilotiq/react`). Columns are switch-dispatched in `SchemaRenderer` — no public registry; subclass an existing `columnType` and lean on `formatStateUsing` for per-row chrome.
19
-
20
- ---
21
-
22
- ## Key Files
23
-
24
- Deep notes for many of these also live in `~/.claude/projects/-Users-sleman-Projects-pilotiq/memory/`.
25
-
26
- - `src/Pilotiq.ts` — Builder: `.path / .branding / .theme / .layout / .resources / .globals / .pages / .schema / .guard / .user(req=>userOrNull) / .use`. Exposes `pilotiq.resolveUser(req)` for routes.
27
- - `src/RelationManager.ts` — **Plan #11** abstract base for parent-scoped projections. Required `static relationship` (matches a key on the parent model's `static relations` map; doubles as URL segment). Optional `label / labelSingular / icon / recordTitleAttribute / relatedResource`. Static `form(form, ctx) / table(table, ctx) / detail(record, parent)` configurators (mirror Resource shape minus URL ownership — no `slug`, no `pages()`). `ctx: RelationManagerContext` carries `basePath / parentSlug / parentId / relationship / parentRecord / related? / mode` so user code can wire `Action.relationCreate / relationEdit / relationDelete(M, ctx)` (or the M2M sibling factories) from inside `static table()` without threading basePath / parentId by hand. **`mode: 'hasMany' | 'belongsToMany' | 'morphMany' | 'morphTo'`** is auto-derived from `parentModel.relations[rel].type` via `getRelationType` + `normalizeRelationMode` in `pageData.relationManagerData` (and at registration time in `routes.ts`). `belongsToMany` flips into pivot-mutation mode; `morphMany` / `morphOne` collapse into `'morphMany'` (parent-side polymorphic — auto-fills `<morphName>Id` + `<morphName>Type` on create + edit); `morphTo` is the child-side polymorphic (no auto-actions, no auto-discovery — set `static relatedResource` explicitly); everything else collapses to `'hasMany'`. Overrides may drop the trailing `ctx` parameter (TS allows narrower override signatures). Form lifecycle hooks (`mutateDataBefore*`, `before/after*`, `save / handleCreate / handleUpdate`) — same shape as `Resource.form()`. Seven `can*` async predicates — `canViewAny / canView / canCreate / canEdit / canDelete / canAttach / canDetach`, defaults all return `true`; the page-data builder defers to the related Resource's matching predicate when the manager hasn't overridden (reference-equality check on the prototype). **`canAttach / canDetach` are manager-only** — they don't fall through to the related Resource because attach/detach are pivot operations, not record operations. `safeManagerPolicy(M, method, Related, user, parent, child?)` — lives in RelationManager.ts so `Action.ts` can reuse it cycle-free; pageData re-exports for back-compat. Reserved relationship tokens (`edit / delete / restore / force-delete / _form / _action / _search / _uploads / _attach / _detach / _bulk-detach`) validated at panel boot. `src/schema/RelationTabs.ts` — schema element built by `buildRelationTabs(R, recordId, base, activeKey, user, parentRecord)`; auto-mounted on EditPage / ViewPage when `R.relations().length > 0` OR `R.getRecordPages()` is non-empty, plus on the three relation page roles for parent-context continuity. **Record sub-pages (2026-05-11):** `Resource.pages()` returns may include a `record: Record<string, typeof Page>` map for custom sub-pages mounted at `${resourceBase}/:id/${subPageSlug}`. Tabs surface in the strip between `__edit` and the manager tabs, in declaration order; gate on `await SubPage.canAccess(user, parentRecord)` (predicate widened with optional record arg — back-compat with existing `canAccess(user)` signatures). Boot validation rejects sub-page slugs that collide with reserved tokens, relation-manager `relationship` slugs, or fail the `[A-Za-z0-9_-]+` pattern. `PageMode` widened with `'record'`. Page data builder `resourceRecordPageData(pilotiq, slug, recordId, subPageSlug, req)` mirrors `resourceViewData`'s shape and runs `R.canAccess + R.canView + SubPage.canAccess` before resolving the sub-page's schema. The route + Vike dispatch share the relation-list URL slot (`${slug}/:id/${rel-or-subSlug}`); `dispatchPageData` tries the relation-manager lookup first, falls through to `resourceRecordPageData` when no manager matches. Plan + guide: `docs/plans/resource-record-sub-pages.md`, `docs/guide/record-sub-pages.md`. **Sub-nav follow-up (2026-05-03 cont'd):** the strip emits `[__view, __edit, …managers]` — both record-mode tabs are siblings rather than one-or-the-other. Each `__view` / `__edit` tab is gated on the corresponding page role being registered in `R.resolvePages()` (defaults ship both, but a `static pages()` override that drops one suppresses the matching tab so it can't 404). `activeKey` (`'__view'` / `'__edit'` / a manager's relationship key) selects the highlighted tab; `mode` parameter dropped — same fn produces every page's strip from a single shape. **Per-tab canX gating (2026-05-11):** the strip also evaluates the matching predicate per tab and drops entries the user can't reach — `__view` runs `R.canView(user, parentRecord)`, `__edit` runs `R.canEdit(user, parentRecord)`, each manager runs `safeManagerPolicy(M, 'canViewAny', Related, user, parentRecord)`. Predicates throwing fail closed (tab hidden); record-aware predicates skip when `parentRecord` is undefined (record-load failure surfaces a 404/403 on the route, so the strip stays consistent). After gating the strip may collapse to zero entries — `buildRelationTabs` then returns `undefined` (consistent with the no-managers branch). `buildNestedRelationTabs(R, M, base, step0, child1Id, activeKey, user, child1Record)` mirrors the same shape for the depth-2 strip; sibling nested manager tabs gate on `safeManagerPolicy(N, 'canViewAny', Related, user, child1Record)`; the back-link `__view` stays unconditional (user already passed `M.canViewAny` to reach the page). Routes still enforce in front of the data builder — this is presentation polish, not security.
28
- - `src/orm/modelDefaults.ts` — `ModelLike / ModelQuery` structural interfaces + `modelSave / modelLoadRecord / modelTableRecords` helpers. `defaultPages(R)` installs them as Form/Table sentinels when `R.model` is set. Pilotiq has zero runtime dep on `@rudderjs/orm`. Plan #11 additions: `ModelLike.relatedQuery?(parent, name)` optional override; default reads `parent.related(name)` (rudder ORM convention). `defaultRelatedQuery / resolveRelatedQuery / modelRelationTableRecords` helpers feed `Table.records()` for managers.
29
- - `src/Cluster.ts` — Filament-style structural cluster (Tier-3, 2026-05-05 cont'd¹⁴). `static label / slug / icon / navigationGroup / navigationSort / navigationLabel / navigationIcon / navigationBadge / navigationBadgeColor / navigationParentItem / canAccess(user) / landingPage`. Resources / Globals / Pages opt in via `static cluster = MyCluster`; URLs gain `${base}/${cluster.slug}/${child.slug}/...` prefix and the child nests under the cluster's nav entry. Boot validates dup slugs, reserved tokens (`_*`, `theme`, `api`), dangling references, top-level slug collisions, and landing-page sanity (must be in `pages()` AND back-reference the cluster). Cluster URL = first accessible child or `landingPage` URL when set. `Cluster.canAccess(user)` AND'd with child predicates — both must pass; failing cluster gate drops every child from nav AND 403s every cluster-prefixed route. Helpers `clusterPaths.ts` exports `resourceBasePath / globalBasePath / pageBasePath / clusterBasePath` used everywhere URLs are built (`routes.ts`, `pageData.ts`, `Resource.getGlobalSearchResultUrl`, `Action` factories via structural `ResourceLike.cluster`). Vite plugin emits `clusterSlugsByBasePath` map alongside `componentRegistry` in `_components.ts`; auto-gen route stubs detect cluster prefix via `clusterOffset(parts)` and shift their part-index reads accordingly so SPA nav works on both shapes. v1 limits: no nested clusters, no aggregated badges, no cluster-internal sidebar. Plan + guide: `docs/plans/clusters.md`, `docs/guide/clusters.md`.
30
- - `src/Resource.ts` — abstract class with **static** methods `label / labelSingular / slug / icon / model / form / table / detail / deleteRecord / pages / resolvePages / relations / getSlug`. Resources register as classes. `model?: ModelLike` opts into auto-wired CRUD. **Nav metadata (Plan #9):** `navigationGroup / navigationSort / navigationLabel / navigationIcon / navigationBadge / navigationBadgeColor / navigationParentItem` (parent ref by JS class name, not slug). Plus `recordTitleAttribute`. **Breadcrumb override (2026-05-06 cont'd⁴):** `breadcrumb?: string` static + `getBreadcrumb()` accessor — falls through to `label` when unset. Reads through every Phase C breadcrumb path (resource list/create/view/edit + the relation prefix used by depth-1 + depth-2 chains). Pair with a longer `label` when the sidebar / page-title plural reads better long ('Blog Posts') but the breadcrumb chain reads tighter short ('Posts'). **Authorization (Plan #10):** six async predicates default `true` — `canAccess / canViewAny / canView / canCreate / canEdit / canDelete`. Throwing → fail closed. Opaque user. **Global search (Plan #12):** `globalSearch` toggle (default `false` — opt-in), `globallySearchableAttributes()` defaults to `recordTitleAttribute` + every `Column.searchable()` from the configured table, `getGlobalSearchResultTitle / Subtitle / Url(record, base)` with sensible defaults (record[recordTitleAttribute] → name → title → id; URL = `${base}/${slug}/${id}`), `getGlobalSearchQuery(needle)` override returns a `ModelQuery` for joined / FTS / non-LIKE backends. **Plan #15 widgets:** `static headerSchema(ctx?) / footerSchema(ctx?)` return widget elements rendered above / below the resource list table; resource-scope widget endpoint `POST {base}/{slug}/_widget/:id` runs `canAccess + canViewAny` in front of widget visibility check. **Filter persistence (Tier-3, 2026-05-05 cont'd¹¹):** `persistFiltersInSession = false` opt-in flag — when true, the GET list handler stashes the URL filter slice (filters / `group` / `search` / `sort` / `perPage`; `page` + `tab` excluded) on `req.session` and 302-redirects bare visits back to the last-applied state. No-ops without `@rudderjs/session`. Helpers in `src/sessionFilters.ts`; guide `docs/guide/filter-persistence.md`. **Defer loading (Tier-3, 2026-05-05 cont'd¹²):** `deferLoading = false` opt-in flag — when true, the SSR pass on the list page calls `tagTableDeferred(elements, '${indexUrl}/_table')` BEFORE `loadTableRecords`, marking every Table on the page as deferred and stamping a `tableUrl`. `loadTableRecords` short-circuits the records handler when `Table.isDeferred()` returns true (URL chrome state — current sort / search / page — still mirrors). The renderer's `<TableRenderer>` shell handles the deferred branch: paints `<TableSkeleton>` on first frame, fetches `tableUrl + window.location.search` after mount with `Accept: application/json`, replaces the skeleton with the response's `tables[0]` meta. New route `GET {base}/{slug}/_table` (registered only when `R.deferLoading=true`) calls `resourceTableData()` which re-runs the same data builder WITHOUT the deferred flag — records load, per-row stamping runs, summaries compute, and `collectTableMetas(schemaData)` returns every `type:'table'` meta as a flat array. SPA nav re-runs SSR → re-paints skeleton → re-fetches; every nav pays one skeleton frame. Composes with `persistFiltersInSession` (bare-visit 302 redirect happens BEFORE the deferred-load decision). Guide `docs/guide/defer-loading.md`. **Soft deletes (Plan #13):** `softDeletes = false` opt-in flag + `deletedAtColumn = 'deletedAt'` column-name override. Two new async predicates: `canRestore(user, record)` (default `true`) and `canForceDelete(user, record)` (default delegates to `canDelete` via `this.canDelete(user, record)` for fail-closed inheritance). Both flags must opt in (rudder side `Model.softDeletes = true` AND pilotiq `Resource.softDeletes = true`); routes throw a clear boot error when only one side is set or when `model.restore / forceDelete` are missing. Wires up: `TrashedFilter` auto-injection on the list page; `Action.restore / forceDelete / bulkRestore / bulkForceDelete` factories; "moved to trash" framing on the standard delete success notification; auto-visibility on `Action.delete` (hides on already-trashed rows) / `Action.restore` + `forceDelete` (show only on trashed rows). Two new routes per soft-delete resource: `POST /:slug/:id/restore` and `POST /:slug/:id/force-delete` (both look up records via `query.withTrashed()` to bypass default scope).
31
- - `src/Global.ts` — singleton resource. Same shape as `Resource` minus list/create/delete. `navigationGroup` defaults to `'Settings'`. Authorization subset: `canAccess / canView / canEdit`.
32
- - `src/Page.ts` — page class: `static slug/label/icon`, `static schema(ctx)`, `static getResource()`, `static getMode() → 'list'|'create'|'edit'|'view'|'custom'`. Carries nav metadata. Single `canAccess(user)` gate.
33
- - `src/defaultPages.ts` — Four base classes `ListPage / CreatePage / EditPage / ViewPage`. Subclass via `static override getResource()`. Factories `defaultListPage(R) / defaultPages(R)`. **Filament-style explicit actions:** `getHeaderActions / getRowActions / getActions` return `[]` by default; users opt in via override or `Resource.table().headerActions(...) / .recordActions(...) / .bulkActions(...) / .actions(...)`. Slots accept `Array<Action | ActionGroup>`. Pre-built factories `Action.create / .edit / .view / .delete / .replicate(R, base, recordId?)` plus `Action.bulkReplicate(R, base, opts?)`. `Action.replicate` is handler-style — clones the source row via `R.model.create(...)` (strips PK + soft-delete column + `opts.excludeAttributes`, optional `opts.beforeReplicaSaved` mutator), redirects to the new record's edit page; visibility delegates to `R.canCreate`. `Action.bulkReplicate` iterates `ctx.records`, applies the same strip + mutate + create pipeline per row, skips rows that throw or fail per-row `canCreate`, notifies with the count. **`Action.relationReplicate(M, ctx, recordId?, opts?) / Action.relationBulkReplicate(M, ctx, opts?)` (2026-05-05)** — relation-manager siblings. Operate on `Related.model` instead of `R.model`; **force-pin** the parent attachment back onto the replica (hasMany → `[foreignKey]: ctx.parentId` from the parent's `static relations[name]` descriptor; morphMany → `<morphName>Id`/`<morphName>Type` via `computeMorphPayload(ctx.parentRecord)`) so a tampered source row can't slip a different parent in by riding its own FK column. Auto-hide on M2M (`belongsToMany / morphToMany / morphedByMany` — replicate doesn't fit pivot semantics) and on `morphTo` (no single owner to pin to). Visibility delegates to `safeManagerPolicy(M, 'canCreate', Related, user, parentRecord)`; bulk variant runs the same gate per-row inside the loop. **`opts.getCreatedNotificationTitle / opts.getRedirectUrl` (2026-05-05 cont'd):** all four replicate factories accept these overrides on `ReplicateOptions`. Single-row factories (`replicate / relationReplicate`) pass `{ replica, source }` to both callbacks; bulk factories (`bulkReplicate / relationBulkReplicate`) pass `{ count, records }` to the title callback (no redirect on bulk — they stay on the list / manager URL). Both callbacks may be sync or async; returning `undefined` falls back to the default copy / URL. Empty string is honored explicitly (not swallowed by `??`). **Plan #10 auto-visibility:** factories auto-attach a `.visible(...)` rule consulting matching policy. `CreatePage / EditPage` expose form lifecycle as optional static fields (`beforeCreate, afterUpdate, handleCreate/Update, mutateFormData*, mutateData*, getRedirectUrl, getCreatedNotificationTitle, getSavedNotificationTitle`); `installLifecycleHooks` wires them on. **List-page tabs:** `ListPage.getTabs() → ListTab[]` wraps in a `ListTabs` container. **Create & create another (2026-05-03):** `CreatePage.getFormActions(R)` ships TWO submits by default — primary `submit` ("Create ${labelSingular}") + secondary outlined `createAnother` ("Create & create another") posting `_continueCreate=1` via `Action.formField()`. The create POST handler routes the redirect back to `/create` (winning over `redirectAfterSave`) and emits `force: true` in the JSON response so the SPA's `FormRenderer` navigates even though the redirect URL matches the current page (the existing same-URL skip would otherwise preserve the just-submitted values on screen). Drop the second button by overriding `getFormActions(R)` to return only the primary submit. **Wizard mode (2026-05-04 cont'd, Filament `HasWizard` equivalent):** `CreatePage.getSteps(R) → Step[]` opt-in (default `[]`); when non-empty, `schema()` builds the form via `R.form()` (lifecycle preserved) then calls `form.schema([this.getWizard(Wizard.make().steps(steps), R)])` to swap the form's children with a Wizard wrapping the steps. Author the wizard's fields directly inside `Step.make(label).schema([…])` — `Resource.form().schema()` children are *replaced*. `getWizard(wizard, R)` chrome customizer for `.skippable() / .startOnStep(n) / .persist(false)` (default returns the wizard untouched). Per-step validation reuses Plan #8's `tagFormWizardUrls / formWizardData` pipeline unchanged (Wizard descendant gets detected, `wizardUrl` stamped, `findWizardStepFields` walks the new tree). EditPage wizard mode deferred.
34
- - `src/Tab.ts` + `src/elements/ListTabs.ts` — `ListTab.make(name).label().icon().badge(s|fn).badgeColor().default().modifyQuery(fn).modifyContext(fn)`. Distinct from the form-side `Tab` (under `Tabs.make()`).
35
- - `src/defaultGlobalPages.ts` — same factory pattern for `Global`; default returns `{ edit }`.
36
- - `src/Column.ts` — `Column.make(name).label().sortable().searchable()`. Cosmetic builders + built-in formatters (`.dateTime, .since, .money, .numeric, .limit, .words, .characters` (alias for `.limit`)). Server-side `.formatStateUsing((value, record) => string)` stashes per-row results on `row._formatted[name]`. **Footer summaries:** `.summarize([Sum/Average/Count/Range])` — `loadTableRecords` computes aggregates over the rendered rows and stamps `TableMeta.summaries[colName]` (`SummaryResult[]`); per-page only in v1. **Rich-display chrome (Filament-parity Tier-2, 2026-05-07 cont'd¹²):** `.listWithLineBreaks() / .bulleted() / .copyMessage(message?) / .markdown() / .html()` plus `sanitize / allowRaw` for the rich-text pair. `listWithLineBreaks` and `bulleted` apply only to array values (bulleted wins when both are set). `copyMessage` mounts a `CellCopyButton` after the cell value (the message doubles as the toast text; default `'Copied!'`). `markdown / html` are mutually exclusive and stamp `richText: 'markdown' | 'html'` onto the meta — `dispatchTable` server-renders matching cells through `marked` (markdown) or passthrough (html), runs the result through `sanitize-html` against `DEFAULT_SANITIZE_CONFIG` (or the user's widened config / `allowRaw`), and stamps `row._formatted[col.name]` + `row._richtextCells[col.name] = true` so the renderer mounts the existing prose-sm `dangerouslySetInnerHTML` path. `formatStateUsing` always wins over `markdown/html` (declared first by the user — assumed deliberate); the auto-richtext (Tiptap) walker filter excludes `isRichText()` columns so they don't double-render.
37
- - `src/summarizers/` — `Summarizer` base + `Sum / Average / Count / Range` subclasses. Each has `.label(text)` (rendered before the value) and `.format((n) => string)` for currency/locale formatting (Count ignores format). `compute(values)` filters out null/undefined/empty/NaN before aggregating — `Number(null)` returning 0 would otherwise pollute averages and ranges.
38
- - **`Column.toggleable(opts?)` (2026-05-10 cont'd⁸):** opt-in user-visibility chrome. Bare `toggleable()` lets the user hide the column from the toolbar's "Columns" dropdown; `toggleable({ initiallyHidden: true })` starts off-screen. State persists to `pilotiq.table.<currentPath>.columns.<col>` localStorage ('1' hidden / '0' visible / null falls back to `initiallyHidden`). Renderer adds a `<ColumnsToggleDropdown>` toolbar trigger next to FilterPopover / SortByPicker; uses `closeOnClick={false}` so users can toggle multiple columns without re-opening. `visibleColumns = columns.filter(c => !hiddenColumns.has(c.name))` replaces `columns` in the TableHead loop, body cells loop, per-group + footer summaries, and the empty-state colSpan. Hidden state is presentational only — data still loads server-side so sort/filter/group on a hidden column keeps working. Sparse meta — `toggleable` key absent unless the column opts in.
39
- - `src/columns/` — `TextColumn / BadgeColumn / IconColumn / BooleanColumn / ImageColumn / TextInputColumn / ToggleColumn / SelectColumn`. Each sets `columnType` via `setColumnType()`; renderer branches on it. `TextColumn.make(name)` is the explicit text-cell factory; `Column.make(name)` is an alias kept for back-compat — both produce identical wire shape (`columnType` defaults to `'text'`, omitted from meta). **Editable cell columns (2026-05-03):** `TextInputColumn / ToggleColumn / SelectColumn` flip `Column.isEditable() === true`; per-row `R.canEdit(user, row)` gate runs in `loadTableRecords` (via the new `LoadTableHooks.canEdit` callback threaded from `resourceIndexData`); successful gates stamp `_cellEditable[col]: true` + `_cellDisabled[col]: true` on the row, and `pageData.tagCellEditUrls(elements, indexUrl)` walks them after to add `_cellEditUrls[col]` (parallel to `tagFormStateUrls / tagTableReorderUrls`). PATCH route at `POST {base}/{slug}/:id/_cell/:column` body `{ value }`; coerce + validate + `R.model.update(id, { [col]: value })`. Boot guard requires `R.model.update` whenever an editable column is present. Renderer in `react/cells/EditableCell.tsx` (`CellTextInput / CellToggle / CellSelect` + `pickEditableCell()`) — debounced (500 ms default) for TextInput, immediate for Toggle / Select; optimistic local state + rollback on failure with error toast; `data-no-row-nav` so cell controls don't trigger row click; opt-in `.confirm(message)` gates the PATCH behind a Dialog. **`SelectColumn.options(record => …)` per-row resolver (2026-05-10 cont'd⁵):** function form receives the raw record and may return a Promise; runs in parallel with sibling resolvers inside the per-row `if (allowed)` block in `dispatchTable.ts` (gated behind canEdit so hidden cells don't pay the resolver cost). Stamps `row._cellSelectOptions[col.name]: ColumnSelectOption[]`; renderer's `<CellSelect>` reads `props.rowOptions` first, falls back to `col.selectOptions`. Resolver-only columns omit `selectOptions` from meta (per-row stamp wins); throwing resolver leaves the slot unset on that row only — others still stamp, and the cell falls back to whatever static `.options()` is set (or empty list) so one bad row doesn't break the whole table. Mixing static + resolver: re-calling `.options()` replaces the prior set in either direction.
40
- - `src/PilotiqRegistry.ts` — globalThis-backed singleton, `findByPath()` for route matching.
41
- - `src/sessionFilters.ts` — Tier-3 filter persistence helpers. `Resource.persistFiltersInSession = true` opts in; the GET list handler in `routes.ts` writes the active query slice (filters / `group` / `search` / `sort` / `perPage`; `page` + `tab` excluded) to `req.session` under `pilotiq:filters:<basePath>:<slug>` on every non-bare visit, and 302-redirects bare visits (zero query params) to the persisted slice. Duck-typed `req.session.get / put` (mirrors `notifications/flash.ts`) so it no-ops when `@rudderjs/session` isn't installed. Idempotent — short-circuits the `put()` when the slice deep-equals the stored value to avoid `Set-Cookie` churn on revisits. v1 = one slot per resource (all tabs share one filter slot); guide: `docs/guide/filter-persistence.md`.
42
- - `src/PilotiqServiceProvider.ts` — Provider + `pilotiq()` factory.
43
- - `src/search.ts` — Plan #12 `searchAllResources(pilotiq, query, user, opts?) → GlobalSearchResult[]`. Walks `cfg.resources` filtered by `globalSearch=true`, runs `canAccess + canViewAny` in parallel, builds a per-resource `ModelQuery` (override-or-default LIKE chain on `globallySearchableAttributes()`), `paginate(1, limitPerResource)`. Returns `[]` for queries shorter than 2 chars. Throws on a single resource silently drop its results; others still resolve. No new ORM contract — reuses the existing `paginate` from Plan #2/#6.
44
- - `src/pageData.ts` — Barrel module (~480 lines) + `dispatchPageData(pageContext)` Vike `+data` switchboard. Re-exports the per-page-role builders (`dashboardData`, `resourceIndexData`, `resourceCreateData`, `resourceEditData`, `resourceViewData`, `resourceRecordPageData`, `globalEditData`, `globalViewData`, `customPageData`, `formStateData`, `formWizardData`, `formCreateOptionData`, `mentionResolveData`, `widgetData`, `searchData`, `relationManagerData`, `panelInfo`, `applyRoleHooks`, `resolvePageHooks`) + the URL-tag helpers + the fill pipeline through the `./pageData.js` import path so external callers (`routes.ts`, tests, custom dispatchers) stay stable. **Plan #5 `formStateData(pilotiq, scope, body, req?)`** drives partial-resolve — runs `applyStateUpdate`, re-resolves form with bound read-only `$get` (resolve-time `$set` is a no-op closure; only `afterStateUpdated` writes survive), returns `{ ok, form: FormMeta, dirty }` or 404/422. **`tagFormStateUrls(elements, urlBuilder)`** stamps `Form.withStateUrl()` only on forms with at least one `live()` descendant. **Plan #8 `formWizardData(pilotiq, scope, body, req?)`** runs `validateSchema` over the requested step's children only — returns `{ ok }` (200) or `{ ok:false, status:422, errors }`. **`tagFormWizardUrls(elements, urlBuilder)`** stamps `Form.withWizardUrl()` only on forms with a `Wizard` descendant. **Navigation tree:** `panelInfo(pilotiq, req?)` is async — flattens resources/globals/pages, resolves `navigationParentItem` refs, sorts by `navigationSort`, parallel-resolves `navigationBadge()` via `Promise.all` (errors swallow). `buildNavigation` pre-evaluates `canAccess(user)` in parallel; failed items dropped. Both rudder GET handlers AND the auto-gen Vike `+data` hook call these so SSR + SPA-nav serve identical data; renderers read from `(ctx.data ?? ctx.viewProps)`. `resolveActiveTab(elements, query, currentPath)` stamps active tab + per-tab URL + parallel-resolved badge counts before `loadTableRecords`.
45
- - `src/pageData/` — Modular page-data subtree. Split out of `pageData.ts` in 6 phases (PR #34–#39, 4,948 → 481 lines):
46
- - `breadcrumbs.ts` — 16 builder helpers (home / cluster / resource×4 / global / customPage / relation×4 + prefix / nestedRelation×4 + prefix) + `RelationChainStep` type.
47
- - `helpers.ts` — `userCtx` / `uploadCtx` (`SchemaContext` builders), `callPageSchema`, 12 URL-tag helpers (`tagFormActions` / `tagFormStateUrls` / `tagTableReorderUrls` / `tagTableDeferred` / `tagCellEditUrls` / `tagFormWizardUrls` / `tagFieldAiUrls` / `tagSelectCreateOptionUrls` / `tagRichTextMentionUrls` / `tagWidgetUrls` / `tagActionDispatch`), the fill pipeline (`applyFillPipeline` + `applyRelationshipRepeaterFill` + `applyRelationshipBuilderFill` + `pickChildPrimaryKey` / `pickChildForeignKey` / `findRelationshipRepeaters` / `findRelationshipBuilders` / `parseBuilderDataPayload`), `resolveServerDataElements` + `collectServerDataElements` + `ServerDataMap`, and field-walker helpers (`walkSelectFields` / `isAsyncMentionField` / `formHasLiveField` / `formHasWizard`).
48
- - `navigation.ts` — `panelInfo` (entry point for chrome envelopes), the meta interfaces (`UserMenuMeta` / `DatabaseNotificationsMeta` / `RightPanelMeta` / `RightSidebarMeta` / `NavItem` / `PanelInfoRoute`), `buildNavigation` + `nestAndSort` + `RawNavItem`, the render-hook resolvers (`resolveChromeHooks` / `applyRoleHooks` / `resolvePageHooks`), and the user-menu / sidebar / notification-meta builders.
49
- - `resourcePages.ts` — `dashboardData`, `resourceIndexData`, `resourceTableData`, `collectTableMetas`, `resolveActiveTab` + `findListTabs` + `buildTabUrl`, `resourceCreateData`, `resourceEditData`, `resourceViewData`, `resourceRecordPageData`.
50
- - `relationPages.ts` — Plan #11 relation manager builders. Types (`RelationManagerScope` / `RelationManagerResult` / `ResolvedChain`), lookup helpers (`findRelatedResource` / `findManager` / `childBelongsToParent` for IDOR), auto-wiring (`autoWireManagerTable` / `injectManagerTrashedFilter` / `autoWireManagerForm` / `safePolicy` / `safeManagerPolicy`), depth-1 builders (`relationManagerData` + `buildRelation*Data`), depth-2 chain resolution + builders (`resolveRelationChain` + `nestedRelationManagerData` + `nestedManagerCtx` + `nestedResponseEnvelope` + `buildNestedRelation*Data`).
51
- - `relationTabs.ts` — `buildRelationTabs` (depth 1) + `buildNestedRelationTabs` (depth 2) + `deriveParentTitle` + `safeBool`. Lives in its own module because both `resourcePages.ts` (parent record view/edit strip) AND `relationPages.ts` (nested-tab strip on record manager pages) consume them — keeps the resource ↔ relation sibling import graph acyclic.
52
- - `forms.ts` — Plan #5 `formStateData`, Plan #8 `formWizardData`, `formCreateOptionData` (SelectField inline-create), `mentionResolveData` (async-mention typeahead), plus per-builder helpers (`stepHookErrorMessage` / `findSelectFieldByName` / `isMentionResolverField` / `findRichTextFieldByName` / `stripRepeaterRowPrefix` / `stripBuilderRowPrefix`) and 9 wire-shape types.
53
- - `misc.ts` — `globalEditData`, `globalViewData`, `customPageData`, `widgetData` (Plan #15 widget polling) + `findWidgetById`, `searchData` (Plan #12 global search), plus the `WidgetScope` / `WidgetRequest` / `WidgetSuccess` / `WidgetFailure` wire-shape types.
54
- - `src/routes.ts` — `registerPilotiqRoutes()` using `view()`. Each GET handler delegates to `pageData.ts`; POST handlers stay here. **Plan #10 wiring:** every handler runs `checkPolicy(() => R.canX(user, …))`; failure → `forbidden(res, json)` (403 JSON or HTML). Record-aware predicates load via `R.model.find(id)` when set. 403 ≠ 401 — `Pilotiq.guard()` is the 401 layer. Routes:
55
- - `GET ${base}` → dashboard
56
- - `GET ${base}/${slug}` → resource list
57
- - `GET/POST ${base}/${slug}/create` → create
58
- - `GET ${base}/${slug}/:id` → view
59
- - `GET/POST ${base}/${slug}/:id/edit` → edit
60
- - `POST ${base}/${slug}/:id/delete` → delete
61
- - `POST ${base}/${slug}/_action/:actionName` → resource action dispatch
62
- - `POST ${base}/${pageSlug}/_action/:actionName` → custom-page action dispatch
63
- - `GET/POST ${base}/${slug}` (Global) → singleton edit
64
- - `GET ${base}/${pageSlug}` → custom page
65
- - **Plan #5 partial-resolve endpoints** — `POST {…}/_form/:formId/state` (resource-create / resource-edit / global-edit / custom-page). Body `{ changed, values }`; `handleFormState(req, res, pilotiq, scope, formId)` shared helper.
66
- - **Plan #8 wizard step-validate endpoints** — `POST {…}/_form/:formId/wizard` (same four scopes). Body `{ step, values }`; `handleFormWizard` shared helper. 200 → advance; 422 → `{ errors }` for inline display; 404 → step out of range or no Wizard on form. Reuses the same policy prelude as the form-state endpoint.
67
- - **Plan #11 relation manager routes** — six handlers per registered `(Resource, RelationManager)` pair. `GET ${base}/${slug}/:id/${rel}` (list), `GET/POST ${base}/${slug}/:id/${rel}/create` (create), `GET/POST ${base}/${slug}/:id/${rel}/:childId/edit` (edit), `POST ${base}/${slug}/:id/${rel}/:childId/delete` (delete). Two-layer auth (parent `canAccess + canEdit`, then manager-scope `canX` with fall-through to related Resource). IDOR check (`childBelongsToParent`) re-runs the relation query before edit/delete. `FormContext` stamped with `parent / parentId / parentRecord / relationship`. Reserved-token guard at panel boot.
68
- - **Polymorphic follow-up — morph-column auto-injection (2026-05-03):** when `mode === 'morphMany'`, the `relation-create` and `relation-edit` POST handlers compose a `mutateDataBeforeCreate` / `mutateDataBeforeUpdate` on the manager form that runs AFTER any user-supplied hook and spreads `computeMorphPayload(parent, descriptor)` into the data — `<morphName>Id` ← `parent[primaryKey]`, `<morphName>Type` ← `parent.constructor.morphAlias ?? parent.constructor.name`. Framework wins last so a tampered POST body (`commentableId=v1&commentableType=Video`) cannot reassign a child to a different polymorphic parent. Helpers `getMorphRelationDescriptor` + `computeMorphPayload` in `orm/modelDefaults.ts`. `morphTo` recognized in the union but no auto-actions inject (target class is dynamic — set `static relatedResource`). Playground demo: `Comment.commentable: morphTo([Post, Video])` + `Post.comments / Video.comments: morphMany`; `PostsCommentsManager` and `VideosCommentsManager` share the same `CommentResource`. Plan: `docs/plans/relations-polymorphic.md`.
69
- - **M2M follow-up — manager-scoped action + detach routes (2026-05-03):** two extra handlers per relation manager. `POST ${base}/${slug}/:id/${rel}/_action/:actionName` — manager-scoped action dispatch; loads parent, runs `canAccess + canEdit`, rebuilds the manager's table via `M.table(Table.make(), managerCtx)` (same context as the data builder so factories see the same shape), finds the named action via `resolveDispatchTarget`, and calls `dispatchAction` with `relation: { parent, parentId, relationship }` stamped onto `DispatchActionInput` so M2M handlers (`relationAttach / relationBulkDetach`) can call `parent.related(rel).attach / detach` without re-loading the parent. Records hydrate against the related Resource's model. `POST ${base}/${slug}/:id/${rel}/:childId/_detach` — direct row-detach for `Action.relationDetach`; M2M-only (404s with a clear message when `mode !== 'belongsToMany'`); IDOR check runs `where(pk, '=', childId).paginate(1, 1)` against the parent's relation accessor before calling `.detach([id])`. Both routes mounted unconditionally — handler-style actions are useful on hasMany too. The M2M action factories live alongside the relation factories: `Action.relationAttach(M, ctx)` (header, modal-form with a `SelectField` populated by `attachFactory.loadAttachableCandidates(parent, rel, relatedModel, recordTitleAttr, limit?)`); `Action.relationDetach(M, ctx)` (row, direct POST to `_detach`); `Action.relationBulkDetach(M, ctx)` (bulk handler that iterates `ctx.records`, applies `M.canDetach` per row, then calls `parent.related(rel).detach(ids)` once). All three auto-hide when `ctx.mode !== 'belongsToMany'`. The standard `relationCreate / relationEdit / relationDelete` factories also auto-hide under M2M (no per-pivot create/edit/delete surface). `attachFactory.ts` lives next to `importFactory.ts / exportFactory.ts` for the same cycle-cleanliness reasons. `ActionContext.relation = { parent, parentId, relationship }` and `DispatchActionInput.relation` carry the request-time parent record from the route into the handler. v1 limitations: no pivot-extras editing (ORM doesn't surface pivot reads), no reorderable pivots (no ORM `orderByPivot`), no polymorphic M2M, no `syncWithoutDetaching` / `toggle` factories. Plan: `docs/plans/relations-m2m.md`.
70
- - **Plan #12 global search** — `GET ${base}/_search?q=…` returns `{ ok, results: GlobalSearchResult[] }`. Panel-level (one route per panel, not per-resource). Per-resource filtering happens inside `searchData`. No 403 on the route — empty results when nothing's accessible. Query length capped at 200 chars; min 2 to fetch; results capped at 25 total / 5 per resource by default.
71
- - `src/vite.ts` — `pilotiq()` Vite plugin: generates `(pilotiq)/` pages + `+Layout.tsx` + `+Head.tsx`. Reads `app/Pilotiq/AdminPanel.ts` (override via `pilotiq({ panels })`) using **jiti**. Emits `pages/(pilotiq)/_components.ts` — manifest with `componentRegistry: { ClassName: ClassRef }` so component-typed icons resolve at render. Re-runs in `configureServer` on panel changes. Panel files must be import-safe.
72
- - `src/icons/` — User-extensible icon registry. `registerIcons({ name: Component })` + `getIcon(name)`. Icon static fields accept string (registry key) or component (lucide / tabler / heroicons / phosphor). `panelInfo()`'s `serializeIcon(value, ownerClassName)` ships strings as-is and components as `{ class: ownerClassName }`. `useIconFor()` resolves at render. `@pilotiq/pilotiq/icons/lucide` exports a baseline. Component detection accepts `function` and `object` (forwardRef / memo).
73
- - `src/schema/` — Unified `Element` model:
74
- - `Element.ts` — abstract base. Contract: `getType(), toMeta()`, optional `_children: Element[]`. **Plan #8 layout-level state:** `visible(rule) / hidden(rule)` accept `boolean | (ctx: LayoutContext) => bool | Promise<bool>` — resolver drops hidden non-Field/non-Action layouts. `columnSpan(n) / columnStart(n) / columnOrder(n)` emit under `meta._layout` and map to Tailwind `col-span-* / col-start-* / order-*` (1..12 clamp).
75
- - **Display:** `Text, Heading, Alert, Divider, Image, Icon, Markdown, Html, UnorderedList`. **`UnorderedList` (2026-05-04 cont'd):** `UnorderedList.make(items?: string[]).items([…]).color(TextColor).size(TextSize).weight(TextWeight)` — bullet list display prime; reuses the `Text` class maps so styling matches neighbouring text in a detail view. **Text formatting (2026-05-03 cont'd):** `.color('default'|'muted'|'primary'|…) / .size('xs'|'sm'|'base'|'lg'|'xl') / .weight('normal'|'medium'|'semibold'|'bold') / .badge(v=true) / .badgeColor(BadgeColor)` — pill rendering reuses `BADGE_COLOR_CLASSES`; bare `Text.make()` keeps the prior `text-sm text-muted-foreground` defaults. **Image:** `Image.make(url).alt(t).width(n).height(n).size(n).rounded()|.circle()`. **Icon:** `Icon.make(registryName).size(px).color(IconColor).label(t)` — string-only (registry-resolved); for component-typed icons used by Resource/Page/Global statics see `IconValue`. **Markdown / Html (2026-05-03 cont'd):** read-only display primes (the `MarkdownField` / `RichTextField` editor counterparts). `Markdown.make(source).gfm(b=true).breaks(b=false).prose(b=true).size('sm'|'base'|'lg')` server-renders the source via `marked` so initial paint shows formatted HTML with no parser shipped to display-only pages. `Html.make(html).prose().size()` passes raw strings through (use for legacy CMS columns or already-rendered fragments). Both wrap in a `prose max-w-none` container with optional `prose-sm` / `prose-lg` modifier when `prose=true` (default). **Sanitized by default (2026-05-07 cont'd⁷)** via `sanitize-html` against `DEFAULT_SANITIZE_CONFIG` (`schema/sanitize.ts`); strips `<script>` / `<iframe>` / `javascript:` / inline event handlers / `style=`. Three escape hatches on both primes: `.allowRaw()` (sugar) / `.sanitize(false)` (explicit) / `.sanitize({ allowedTags, allowedAttributes, allowedSchemes })` widening config. The renderer collapses both to a single `case 'markdown' | 'html'` branch in `renderElement` since shapes are identical (server-converted html string + prose flag). **Head-safe (2026-05-07 cont'd⁸):** `MetaTag.make({ name | property | httpEquiv | charset, content? }) / LinkTag.make({ rel, href?, mimeType? }) / ScriptTag.make({ src? | body?, async?, defer?, dataAttributes? }) / StyleTag.make(css, { nonce? })` — value-Element tags emitted ONLY by `panels::head.* / panels::scripts / panels::styles` render-hook callbacks. Body-level Elements inside head slots get skipped with a console warning. `mimeType` on Link/Script renames the HTML `type=` attribute (which collides with the wire-shape discriminator); renderer maps back. Mounted via `<HeadHooks position="start"|"end">` from `@pilotiq/pilotiq/react` inside the auto-generated `+Head.tsx`. **Containers:** `Card, Section, Tabs/Tab, Grid, Group, Fieldset, Split, Wizard/Step` (Plan #8). **Section polish (Plan #8):** `icon(name) / badge(text) / aside(v=true) / compact(v=true) / persistCollapsed(key?)` (key auto-derived from page slug + title). `aside()` flips into right-rail when nested in `Split`. `persistCollapsed` writes `pilotiq.section.<key>` to localStorage on toggle. **`dense(v=true)` (2026-05-03)** — orthogonal to `compact()`: `compact` trims outer padding + heading size; `dense` trims the inner grid `gap` between children (gap-2 / 8px vs gap-4 / 16px). Compose both for a settings-page look. **`secondary(v=true) / afterHeader([Action…])` (2026-05-07 cont'd¹¹)** — Filament v5 chrome polish: `secondary` flips the surface to `bg-muted/40 border-muted` so the section recedes beneath a primary card; `afterHeader` mounts right-aligned `Action[]` buttons in the section header (resolved through the standard walker so `.visible() / .disabled()` rules evaluate the same way as anywhere else; v1 is `Action[]` only — Element-typed children would require a second resolver pass).
76
- - `Wizard.ts` — `Wizard.make().steps([Step.make(label).icon(name).description(d).schema([…])]).skippable().startOnStep(n).persist(false)`. All steps' children resolve every cycle so cross-step `$get` works; client renderer hides inactive steps via `display:none` so controlled inputs survive transitions. Active step persists to `pilotiq.wizard.<formId>.step` localStorage key. Next button POSTs `{ step, values }` to `Form.wizardUrl` — 200 advances, 422 stamps inline errors, missing url advances immediately. **Wizard nav polish (2026-05-10):** `.persistStepInQueryString(key='step'|true|false)` mirrors the active step to the URL as `?<key>=N` (1-based) via `history.replaceState` (purely client-side state sync, no SSR re-fetch); URL wins over localStorage on initial mount so deep-linking works; multi-wizard pages need distinct keys to avoid collisions. `.submitAction(a => a.label('Create campaign')) / .nextAction(...) / .previousAction(...)` customize the chrome of the built-in nav buttons — the customizer receives a framework-built default `Action` (Submit / Next / Back) and returns a customized clone (or fresh `Action`); chrome (label/icon/color/size/outlined/iconOnly/tooltip/disabled rules) carries through to the rendered button while click behavior stays hardwired (advance / recede / submit-form). `submitAction` is the opt-in case: by default the wizard renders a hint pointing at the page-level Save; setting `submitAction` mounts a real `<button type="submit">` inside the wizard chrome on the final step (pair with `CreatePage.getFormActions(R) → []` to suppress the page-level Save when the wizard is the entire form). Resolved through `resolveSchema`'s `if (el instanceof Wizard)` branch so `.visible(false)` on a customizer cleanly drops the slot from meta (renderer falls back to default chrome).
77
- - `Split.ts` — two-column layout. `.from('left' \| 'right').gap(n).schema([main, aside])`. Aside child detected via `Section.aside()` marker; falls back to second child. Renders `flex flex-col @md:flex-row` with `@container` query so nested Splits behave.
78
- - `Fieldset.ts` — labeled border container. `.columns(1\|2\|3).schema([…])`. `<fieldset><legend>` semantics, lighter than Section.
79
- - `Group.ts` — chrome-less container. Pass-through `<div>`. Useful for visibility gating without imposing border/heading.
80
- - **Plan #15 widgets (2026-05-03):** `ServerDataElement` abstract base + `View / StatsOverview / Stat / TableWidget` (core) + `Chart` (in `@pilotiq/recharts`). `Stat.make(label).value().description().descriptionIcon(name, position?).icon().color().chart([sparkline]).url().openUrlInNewTab().extraAttributes()` — Stat is a fluent value object, NOT an Element (no place in tree; emitted by `StatsOverview.getStats`). `StatsOverview` extends ServerDataElement; subclass form `static columns / static async getStats(ctx)` or fluent `.columns(n).getStatsHandler(fn)`; resolved Stats serialized to `StatMeta[]` so renderer never touches the class. `View` extends ServerDataElement; `static componentName / static getData(ctx)` (or `.component(name).getDataHandler(fn)`); component lookup via `@pilotiq/pilotiq/widgets` runtime registry (`registerWidgetComponents({ Name: Component })`); core ships `react/widgets/ViewRenderer.tsx` that consumes `getWidgetComponent(meta.component)` and falls back to inline error panels for missing-component / missing-name / hook-error. `TableWidget` extends ServerDataElement; subclass form `static label / model / viewAllUrl / columns() / async query(q) / async records(ctx)`; fluent form `TableWidget.make(id).label().model(M).query(fn).records(fn).columns([…]).viewAllUrl(href)`; resolution falls through `instance.records → static records → instance model+query → static model+query → throws`; default query hook = `q => q.paginate(1, 5)`; per-row `formatStateUsing` runs in resolver and stamps `row._formatted[col]`; columns inline under `meta.columns` (NOT `meta.children`) so resolver doesn't double-walk; named distinct from `Table` (which drives Resource list pages). All four ship `lazy: true` default + `.poll(seconds)` setter + `serverData: true` sentinel + auto-stamped `id` (subclass class name fallback) — every widget element is one of: `case 'stats'/'tableWidget'/'view'` in `SchemaRenderer` OR dispatched through `getWidgetRenderer(type)` for adapter-package widgets.
81
- - **`Pilotiq.dashboard(P)` sugar:** stores `cfg.dashboardPage`, auto-adds page to `cfg.pages` (no dupes), `dashboardData()` resolves P's schema instead of `cfg.schema`, `panelInfo()` collapses page nav URL to `${base}`, slug-route registration skips it. Convention: `static slug = ''` so the regular slug-route doesn't double-catch.
82
- - **Resource header/footer schema:** `Resource.headerSchema(ctx?) / footerSchema(ctx?)` statics return `Element[]` (default `[]`). `ListPage.schema` is async; slots between `getHeader()` and `ListTabs+Table` (header) and after `Table` (footer). `resourceIndexData` calls `tagWidgetUrls + resolveServerDataElements` and stamps `_widgetData`. New `WidgetScope` variant `{ kind: 'resource'; slug }` + `POST {base}/{slug}/_widget/:id` route runs `R.canAccess + R.canViewAny` in front of widget visibility check.
83
- - **`react/WidgetDataContext.tsx`:** `WidgetDataProvider` ferries `_widgetData` map into render tree. `useInitialWidgetData(id)` reads SSR slot; `useWidgetData(meta)` owns lifecycle — initial paint reads `_widgetData[id]`, lazy mount-fetch fires when `null + widgetUrl + !error`, polling setInterval pauses while tab hidden, latest-wins seq-tracking drops stale responses, error-sentinel `{ error: '…' }` round-trips through hooks failures. `tagWidgetUrls(elements, urlBuilder)` walker stamps `meta.widgetUrl` on every server-data element (panel-scope on dashboard, page-scope on custom pages, resource-scope on resource header/footer). Walker stops at `form/repeater/builder/table` containers — widgets-inside-arrays unsupported in v1.
84
- - `resolveSchema()` — async recursive walker. `RenderContext { mode?, record?, basePath?, recordId?, values?, $get?, $set?, changed?, user? }`. **Plan #5:** when `values` is present, conditional callbacks and `SelectField.options(fn)` see `$get/$set`. **Plan #8:** layout elements (anything not Field/Action/ActionGroup) with a `visible(rule)` are evaluated against a `LayoutContext` (same shape as `ConditionContext`); throwing → fail-closed (hidden + warn). `Element.toMeta()` may return `Record | Promise<Record>`. After `toMeta()`, `meta._layout = { columnSpan?, columnStart?, columnOrder? }` is stamped when any positioning method was used.
85
- - `src/elements/` — first-class container Elements:
86
- - `Form.ts` — `Form.make().schema([...])` + lifecycle setters (`validate, mutateData*, before*, save, handleCreate/Update, after*, redirectAfterSave, loadRecord, fillFromRecord, mutateFormData*, savedNotification, createdNotification, disableSavedNotification`). Render-time setters `withValues / withErrors / withStateUrl / withWizardUrl`. `toMeta()` emits `formId / method / action / values / errors / stateUrl / wizardUrl`. Auto-generated `formId`; multi-form pages discriminate via hidden `_formId` input.
87
- - `Table.ts` — `Table.make().columns([...]).records(fn).defaultSort.paginate`. `records(ctx) → { rows, total }` or bare row array. Render setters `withRows / withSort / withSearch / withPage`. **Grouping (Tier-2 follow-up to Batch B, 2026-05-03):** `Table.defaultGroup(string | TableGroup)` widened — bare-column form still works (band rows by `column`), `TableGroup` form auto-registers in `groups([…])`. `Table.groups([TableGroup.make(col).label(text).collapsible().collapsed().getTitleFromRecordUsing(r=>str).getDescriptionFromRecordUsing(r=>str).date()])` — multi-option group selector. URL key `?group=col` selects the active option; `?group=` (empty) explicitly disables; absent falls back to `defaultGroup`. Reserved query keys: `search/sort/page/perPage/group`. `parseActiveGroup(query, table)` reconciles URL → active column. `dispatchTable.loadTableRecords` calls `withActiveGroup(col)` after reconciliation; `toMeta` then emits `defaultGroup` as the resolved active column (NOT the configured one). Per-row stamping: `_groupValue` (raw / `YYYY-MM-DD` for date), `_groupTitle?` (resolved title — user handler wins over `date()`'s default formatter), `_groupDescription?`. Throwing handlers fall silent. Renderer ships a `<TableGroupPicker>` Select dropdown above the table when 2+ groups (or 1 with rich metadata); collapsible heading rows show a chevron + per-group fold state persisted at `pilotiq.table.<currentPath>.groups.<column>.<value>`. **Per-group summarizers (2026-05-03 cont'd):** when an active group is set AND at least one column has `summarize([…])`, dispatcher also computes summaries within each group bucket and stamps `TableMeta.groupSummaries: Record<groupValue, Record<colName, SummaryResult[]>>`; renderer emits an inline summary row at the END of each group band, aligned to the global `<tfoot>` columns, suppressed when the group is collapsed. **Manual group ordering (2026-05-03 cont'd):** `TableGroup.orderUsing((a, b) => number)` overrides the default alphabetic comparator on group keys; sugar helper `orderByKeys([…])` exported alongside the class. `dispatchTable.sortRowsByGroupValue` accepts the comparator; empty-bucket-last + within-group stability stay structural (run before/after the user's comparator respectively). `scopeQueryByKey` (click-to-drill-in) still deferred — coupled enough to filter wiring to deserve its own pass. **`Table.queryStringIdentifier(id)` (Tier-3, 2026-05-05 cont'd¹³):** namespaces every URL key for one table under `${id}_<key>` so multiple tables on one page (custom-page case) don't fight over `?search=` / `?sort=` / etc. Server-side: `parseTableQuery / parseFilterValues / parseActiveGroup` accept an optional `prefix`; `loadTableRecords` reads each table's prefix from `Table.getQueryStringIdentifier()` and parses its slice independently. Renderer-side: `prefixK(prefix, key)` mirrors the join inline, threaded into `buildTableQuery`, the search input (`name="<id>_search"`), `FilterSelect / FilterMultiSelect / FilterDateRange / FilterForm / ActiveFiltersBar / FilterPopover / TableGroupPicker` (all accept `prefix`), the `urlGroup` reader, and a new `<SearchFormHiddenInputs prefix>` that mirrors current URL state into the native search form so a bare `<form method="get">` Enter-submit doesn't drop sort / filter selections (or other tables' params). `sessionFilters.ts` extends its excluded-page-key check to drop both bare `page` and any `<prefix>_page` so persistFiltersInSession composes; deferred `_table` JSON endpoint composes naturally (server reads each table's prefix). Empty / non-`[A-Za-z0-9_-]+` ids throw at config time. Guide: `docs/guide/query-string-identifier.md`. **`Table.filtersLayout(position)` (Filament v5 parity, 2026-05-06 cont'd⁸):** swaps the toolbar Filters popover for an inline strip in the matching slot. `'modal'` (default) preserves today's behaviour; `'above-content'` always-visible strip above the table; `'above-content-collapsible'` adds a toolbar toggle that flips a `pilotiq.table.<currentPath>.filters.open` localStorage slot (defaults open when the URL carries any filter value); `'below-content'` strip after pagination. Inline modes hide the active-filter pill row — every widget already shows its current value, and per-widget clear affordances stay. Sidebar positions (`BeforeContent` / `AfterContent`) reshape the page rather than the table chrome and are deferred until a consumer asks. Pure renderer change — no ORM contract changes, no URL-state changes.
88
- - `TableGroup.ts` — value-object primitive owned by `Table.groups([…])`. Fluent setters: `label / collapsible / collapsed / getTitleFromRecordUsing / getDescriptionFromRecordUsing / date / orderUsing(comparator) / scopable(b=true) / scopeQueryByKey(fn) / getKeyFromRecordUsing(fn)`. Helpers `bucketDateValue(raw) → 'YYYY-MM-DD'`, `formatDateBucketTitle(raw) → 'May 4, 2026'`, and `orderByKeys([…]) → comparator` exported for downstream tests / custom formatters. **Click-to-drill (2026-05-11):** `.scopeQueryByKey((q, key) => q.where(col, '=', key))` opts heading text into a real `<a href>` that drills the table into one bucket — banding suppressed, "Drilled into <Label>: <Value>" chip mounts above the table with × to clear. `.scopeQueryByKey()` / `.getKeyFromRecordUsing()` both auto-arm `.scopable(true)`. URL state: dedicated `?<prefix>groupKey=<value>` (added to `RESERVED_QUERY_KEYS` so filter names can't collide); `?page` resets to 1 on drill-in; `<prefix>groupKey` excluded from `persistFiltersInSession` slice (drill-in is page-state, not filter-state). `dispatchTable.parseActiveGroupKey(query, table, activeCol, prefix?)` reconciles the URL value against the registered scopable group; threads through `ctx.groupScope: { group, key }` into the records handler so `modelTableRecords` + `modelRelationTableRecords` splice the scoper after filters/tab. Default scoper for `.date()` groups installs a whole-day range (`>=` `YYYY-MM-DD 00:00:00` + `<=` `YYYY-MM-DD 23:59:59`); plain groups default to exact-match. Per-group summaries suppressed in drilled-in render; global `tfoot` summary still computes over the visible bucket. Renderer's group heading becomes a `<GroupHeadingLink>` (SPA-nav via `useNavigate()`, cmd-click / right-click "open in new tab" works); collapsible chevron stays as a separate button so folding doesn't drill in. Plan + guide: `docs/plans/table-group-scope-query-by-key.md`, `docs/guide/grouping.md`.
89
- - `dispatchForm.ts` — `dispatchFormSubmit(form, body, ctx)` runs full lifecycle. Form-level validator errors land under `_form` key. `findForms / selectForm` for multi-form. **Plan #5:** `applyStateUpdate(form, values, changed, ctx)` finds the changed field, coerces only that one, runs `afterStateUpdated`, returns `{ values, dirty }`. **Plan #8:** `findWizardStepFields(formChildren, stepIndex)` walks the form's tree, returns the children of the requested step (or `undefined` when no Wizard / step out of range).
90
- - `dispatchTable.ts` — `parseTableQuery / loadTableRecords`. Walks tree, calls every `Table.records(ctx)` in parallel, mirrors state back.
91
- - `dispatchAction.ts` — `findActions / parseActionBody / dispatchAction`. Routes auto-stamp `Action.dispatchUrl()`.
92
- - `src/fields/` — `Field` base + 20 subclasses (`TextField, EmailField, NumberField, SelectField, TextareaField, ToggleField, DateField, SlugField`, **Plan #6:** `HiddenField/Hidden, CheckboxField/Checkbox, RadioField/Radio, CheckboxListField/CheckboxList, SliderField/Slider, ColorPickerField/ColorPicker, DateTimePickerField/DateTimePicker, KeyValueField/KeyValue, FileUploadField/FileUpload`, **Plan #14:** `RepeaterField/Repeater`, **Tier-2 micro-plans (2026-05-04):** `ToggleButtonsField/ToggleButtons` — sugar over Radio with chip rendering; same `OptionsResolver` shape; no separate coerce branch (string passthrough). `TagsInputField/TagsInput` — free-text tag chips; `string[]` value; `.suggestions(string[]|resolver) / .separator(',') / .splitKeys(['Enter']) / .reorderable() / .maxTags(n)`; wire format = JSON-encoded array in a single hidden input; new `tagsInput` coerce branch parses back to `string[]`. **`reorderable()` (2026-05-03):** chips become draggable via native HTML5 drag-and-drop with a 2px vertical drop indicator and a grip-icon affordance; reuses `reorderRows` helper from `RepeaterInput`. Off by default — most tag sets are insertion-ordered or alphabetized. **`MarkdownField/Markdown` (2026-05-04 cont'd)** — plain-markdown editor; raw string value (same wire format as `TextareaField`, no new coerce branch); `.toolbarButtons([…]) / .disableToolbarButtons([…]) / .minHeight(css) / .maxHeight(css) / .fileAttachmentsDirectory(d) / .fileAttachmentsVisibility('public'\|'private')`. Default toolbar = 10 buttons (`bold/italic/strike/link/heading/bulletList/orderedList/blockquote/codeBlock/attachFiles`). Renderer ships a tab switcher (Write / Preview); preview HTML is rendered client-side via `marked` (`gfm: true, breaks: false`), no DOMPurify in v1 (admin-trusted authors). `attachFiles` reuses the existing `_uploads` route + `UploadAdapter`; the button is stripped server-side when no adapter is registered via the new `RenderContext.hasUploadAdapter` flag (see "RenderContext" note below). Paste-image splices `![alt](url)`; ⌘B/⌘I/⌘K shortcuts). Visibility flags + condition callbacks (`showWhen, hideWhen, disabledWhen`), validators via `.validate(v|v[])`. **Plan #5:** `Field.live(opts?: boolean | { onBlur?: boolean; debounce?: number })` marks re-resolve trigger; `Field.afterStateUpdated((value, ctx) => …)` server hook; `SelectField.options(fnOrArray)` async-aware; `FieldCondition` widened from `(record) => bool` to `(ctx: ConditionContext) => bool`. Conditions skip when both `record` and `values` are undefined. **`Field.afterStateUpdatedJs(body)` (2026-05-04 cont'd, Tier-2 follow-up):** client-only counterpart to `afterStateUpdated`. Body string compiled via `new Function` (CSP `unsafe-eval` required) with `$state / $get / $set` bound; cached by source-string identity in `react/fieldJsHandler.ts`; runs synchronously on every change (no `live()` required, no roundtrip). Compose with the server hook — JS runs first, server response (when `live()` is also set) overlays sibling values. Repeater/Builder rows: dotted-path `$get / $set` route through `readNestedValue / writeNestedValue` (snapshot-then-overlay so `$get(thisField)` matches `$state`). `tagFormStateUrls` stamps `stateUrl` for forms with EITHER `live()` OR `afterStateUpdatedJs` so the `FormStateProvider` mounts (the endpoint is unused for JS-only forms; client only POSTs on `live`). **Plan #6 cross-field plumbing on `Field`:** `prefix(string|{icon}) / suffix(...) / helperText(text) / inlineLabel(v=true) / default(value) / dehydrated(false) / formatStateUsing(fn)`. **`inlineLabel()` (2026-05-04 cont'd)** mirrors the `Entry.inlineLabel` shipped with Plan #16 — `FieldShell` swaps to a `flex items-baseline gap-3` row with a `min-w-32` label column (default label-above unchanged); helperText still renders under the value. Bare `inlineLabel(false)` clears. `dehydrated(false)` drops the field from the POST body before validation. `OptionsResolver` extracted to `fields/optionsResolver.ts` and shared by SelectField/Radio/CheckboxList. FileUpload reads `ctx.uploadUrl` (panel-level URL stamped by `pageData.uploadCtx`). New coerce branches: `checkbox` (toggle-like), `slider` (number-like), `dateTime`, `checkboxList` (string[]), `color`, `keyValue` (JSON-string → object), `fileUpload` (URL string or string[]). **Plan #14 `RepeaterField`:** array-of-subschema field; `.schema([...]) / .columns(n) / .defaultItems(n) / .min/maxItems / .reorderable() / .collapsible().collapsed() / .accordion() / .cloneable() / .itemLabel(row=>str) / .addActionLabel(text)`. **`accordion()` (2026-05-06):** one-row-open-at-a-time mode; auto-arms `collapsible()`. Renderer swaps the per-row `collapsed[id]` map for a single `accordionOpenId: string | null` slot; persisted under `pilotiq.repeater.<formId>.<name>.accordion`. Compose with `collapsed()` for "all start closed" (default opens first visible row). Available on Builder too — same shape, persisted under `pilotiq.builder.<formId>.<name>.accordion`. **`expandAction(b) / expandAllAction(b?) / collapseAllAction(b?)` (2026-05-08, audit gap #7):** widens `RowButtonKind` from 7 → 10 slots. `expandAction` is a sibling of `collapseAction` for the *collapsed* state only (open state still routes through `collapseAction`; bare `collapseAction` continues to cover both states for back-compat). `expandAllAction / collapseAllAction` are opt-in field-header bulk chips — calling enables (with or without a `RowButton` override), auto-arms `collapsible()`. Renderer mounts via shared `<BulkCollapseHeader>` above the rows in both Repeater and Builder; accordion mode preserves "only one open" by opening the first visible row on `expandAll` and clearing `accordionOpenId` on `collapseAll`. Per-row mode iterates every row id and writes the `pilotiq.repeater|builder.<formId>.<name>.<rowId>` storage slot so reload restores the bulk state. Table mode skips the chrome (collapsing is meaningless on `<tr>` rows). All three serialize through the existing `serializeRowButtons` helper — no new wire-shape branch. Server-resolved `meta.rows: { id, children, itemLabel? }[]` + `meta.template`. `coerceFormValues` runs a Repeater pass before generic field coercion (handles JSON + flat-key bodies, trims trailing untouched rows, recurses into nested Repeaters). `validateSchema` recurses; per-row errors keyed `items.<i>.<name>`; `min/maxItems` under bare key. `applyStateUpdate` resolves dotted paths; row-scoped `$get/$set` + `ctx.row` exposed to `afterStateUpdated`; cross-row reads via dotted paths. Inner `Section.visible(({ values }) => …)` sees row-scoped values. Walkers (`findForms / findActions / findTables / findFieldByName / validation walk / coerce walkFields`) STOP at Repeater boundary via structural `isRepeaterField` helper. Client renderer `react/fields/RepeaterInput.tsx` uses `FormIdContext` for localStorage keying; uncontrolled inputs with name-prefixing per row preserve typed values across reorder/clone/collapse. Reorder via native HTML5 drag-and-drop (grip handle + 2px drop indicator) plus Up/Down buttons as keyboard fallback — both routes through pure helper `reorderRows(rows, fromIdx, insertBeforeIdx)`. Inner-field live re-resolve roundtrip wired via container-level delegated `onChange / onBlur` in `RepeaterInput`: dotted-path `target.name` → `findFieldMeta(formMeta, name)` (now walks Repeater rows) → `formState.triggerLive(name, value)`. `FormStateProvider` snapshots the form's full DOM via `parseFormDataToNested(new FormData(formRef.current))` then writes the override through `writeNestedValue`, so the server sees fresh cross-row values + the just-typed leaf. `useFieldState` returns `controlled: false` for dotted names (preserves uncontrolled-input reorder behavior) but still proxies `triggerLive`. **Plan #14 v1.2 (2026-05-02):** React-controlled primitives that update via callbacks (Switch / Slider / Base UI Select / Checkbox / Radio / DateField / DateTime / Color / KeyValue / FileUpload / CheckboxList) call `fs.triggerLive(value)` explicitly in BOTH controlled and uncontrolled paths so their inner `live()` fires inside Repeater rows. Native inputs (text/number/email/textarea/range/date/checkbox/radio) still bubble through `RepeaterInput`'s container-level delegate as before. **Row-level visibility:** `Repeater.itemHidden(rule)` — boolean or `(LayoutContext) => bool | Promise<bool>`; truthy stamps `RepeaterRowMeta.hidden = true`. The renderer keeps hidden rows mounted via `display: none` (no chrome, no drag wiring) so inputs + `__id` round-trip through FormData on submit — visibility is purely UX, never a data filter. Move Up/Down skips hidden neighbours; throwing predicate fails-closed-as-VISIBLE (inverse of layout `visible()`'s posture — silent hiding of in-progress data is the worse failure mode). `itemHidden` re-evaluates on every server resolve (initial SSR, `live()` re-resolve, full submit re-render); the renderer-side `RepeaterInput` / `BuilderInput` sync `hidden / canDelete / canClone / canReorder` from fresh `meta.rows` by row id via `react/fields/syncRowGates.ts` so locally added rows + uncontrolled-input typed values survive the round-trip. Throwing predicate fails-closed-as-VISIBLE (inverse of layout `visible()`'s posture — silent hiding of in-progress data is the worse failure mode). **Plan #14 follow-up — `BuilderField/Builder` (2026-05-02):** heterogeneous-row sibling. `Block.make(name).label().icon().columns().schema([…]).maxItems(n)` defines block types; `Builder.make(name).blocks([…]).reorderable().cloneable().collapsible().blockNumbers().blockPickerColumns(n).addable(false).deletable(false).reorderableWithButtons().addActionAlignment('start'\|'center'\|'end').itemLabel((data, blockName)=>str).itemHidden(rule)`. Storage envelope: `[{ __id, type, data: {…block fields} }]`. Server-side: `resolveBuilderRows` resolves each row against the block matching `row.type`, ships `meta.rows: { id, type, children, itemLabel?, hidden?, unknownType? }[]` + `meta.blocks: { name, label, icon?, columns?, maxItems?, template }[]` (per-block resolved templates so the picker can mount fresh rows without a roundtrip). `coerceBuilderValue` folds JSON + flat-key bodies into the envelope shape; `validateBuilder` keys per-row errors as `name.<i>.data.<child>`, total-count + per-block-type maxItems land under bare key. `applyBuilderStateUpdate` handles dotted paths `name.<i>.data.<leaf>` (the literal `data` segment distinguishes from Repeater's `name.<i>.<leaf>`) — picks the block from `values[name][i].type`, resolves leaf from block schema, runs `afterStateUpdated` with row-scoped `$get/$set` + `ctx.row.blockType`. Walkers (`findForms / findActions / findTables / findFieldByName / coerce walkFields / validate walk`) stop at Builder via structural `isBuilderField` helper alongside `isRepeaterField`. Unknown block types in submitted data round-trip verbatim (renderer shows a placeholder, server passes data through) — config rollbacks never silently lose content. Client renderer `react/fields/BuilderInput.tsx` reuses `reorderRows` from `RepeaterInput`; the picker dropdown is a plain `useState`-driven menu (no Popover dep) that greys out per-block-type capped options; row header carries block icon (via `useIconFor` from `react/icon-context.tsx`) + optional 1-based numbering; localStorage keying namespaces under `pilotiq.builder.<formId>.<name>.<rowId>` (parallel to repeater). **`Repeater.relationship(name | { name, model?, foreignKey?, orderColumn? })` (2026-05-03):** stores rows in a real `HasMany` relation on the parent record instead of a JSON column. Field surface `.relationship(arg).orderColumn(col)`; mutually exclusive with `simple()` and `dehydrated(false)`. Wire shape `meta.relationship?: { name, orderColumn? }` (server-only `model` / `foreignKey` never serialize). Load: `pageData.applyRelationshipRepeaterFill(form, values, record, R.model)` reads rows from `parent.related(name)` via `resolveRelatedQuery`, stamps `__id = String(child.pk)`, strips PK + FK from each rendered row. Save: `dispatchFormSubmit` calls `extractRelationshipRepeaters` after `coerceFormValues + unwrapSimpleRepeaters` (deletes the field's value from `data` so the parent's `M.update / create` never sees it), then after `persist()` returns the parent record runs `persistRelationshipRows` — diff submitted rows against `parent.related(name)` rows: `__id` matching an existing PK → `M.update(__id, row)` (FK NOT overwritten, defends against tampered re-link), `__id` absent or non-matching → `M.create({ ...row, [foreignKey]: parentPk })`, existing PKs missing from submitted set → `M.delete(pk)`. `orderColumn` (when set) stamps the row's 0-based index on every create + update payload. Routes (`POST /:slug/create`, `POST /:slug/:id/edit`) thread `parentModel: R.model` onto the `FormContext`; the pipeline throws a clear error when missing. ORM contract additions: `getParentRelationDescriptor(parentModel, name)` reads `static relations[name]` from the parent (rudder convention `{ type, model: () => ChildModel, foreignKey }`), returns `undefined` when absent — caller throws with a config message pointing at the override paths. **M2M family shipped 2026-05-05 cont'd⁸** — `belongsToMany`, `morphToMany`, `morphedByMany` all dispatch through `parent[rel]().attach/detach/sync` (resolved via `resolveM2MAccessor` in `src/orm/m2mAccessor.ts`). Row-create → `M.create()` then `accessor.attach([newPk])`. Row-update → `M.update()` (pivot untouched). Row-remove → `accessor.detach([pk])` only — **no `M.delete`** (the related child may be linked to other parents; cascadeDelete deferred). `cfg.orderColumn` rejected with a clear error under M2M (ORM has no `orderByPivot`). New ORM helper `getM2MRelationDescriptor(parentModel, name)` recognizes the three M2M `type` strings; called FIRST in `resolveChildAndAttachment` (a belongsToMany entry has no `foreignKey` so it'd silently fall through to the hasMany branch otherwise). Plan: `docs/plans/repeater-relationship-m2m.md`. Remaining gaps: pivot-extras editing (ORM v1 doesn't surface pivot reads), `cascadeDelete: true` opt-in. No transaction wrapper around parent + child diff in v1 — partial failure leaves the parent saved. Plan + memory: `docs/plans/repeater-relationship.md`, `docs/guide/repeater.md` "Relationship-backed rows" section, `project_pilotiq_repeater_relationship.md`. **`Builder.relationship(name | { name, model?, foreignKey?, typeColumn?, dataColumn?, orderColumn? })` (2026-05-05 cont'd³):** heterogeneous-row sibling. Each row persists as a child record carrying a discriminator column (default `'type'`, holds the block name) + a JSON payload column (default `'data'`, holds the per-block inner-schema values verbatim) — column names overridable via `typeColumn` / `dataColumn`. Field surface `.relationship(arg).orderColumn(col)`; mutually exclusive with `dehydrated(false)`. Wire shape `meta.relationship?: { name, typeColumn?, dataColumn?, orderColumn? }` (server-only `model` / `foreignKey` never serialize). Load: `pageData.applyRelationshipBuilderFill(form, values, record, R.model)` runs after `applyRelationshipRepeaterFill` in `resourceEditData`; reads rows from `parent.related(name)`, stamps `{__id, type, data}` per row, JSON-parses string `data` columns, strips PK + FK + type/data from each rendered row payload. Save: `dispatchFormSubmit` runs `extractRelationshipBuilders` after the Repeater extract (deletes the field's value from `data`), then after `persist()` returns runs `persistRelationshipBuilderRows` — diff submitted rows: `__id` matching an existing PK → `M.update(__id, { [typeColumn]: row.type, [dataColumn]: row.data, [orderColumn]: idx })` (FK NOT overwritten); `__id` absent or non-matching → `M.create({ [typeColumn]: row.type, [dataColumn]: row.data, [foreignKey]: parentPk, [orderColumn]: idx })`; existing PKs missing from submitted set → `M.delete(pk)`. Type column rewrites on update — block type can switch between submits. Reuses Repeater's existing routes-side `parentModel: R.model` threading + `getParentRelationDescriptor` ORM helper. v1 = `hasMany` + `morphMany` / `morphOne` only; **M2M family deliberately deferred** (heterogeneous `{type, data}` envelope doesn't compose with pivot semantics — `resolveBuilderChildAndAttachment` raises a clear error pointing at `Repeater.relationship`). Per-block-type model dispatch (each block points at a different child model) is the prerequisite for ever supporting M2M on Builder; deferred until a consumer asks. Plan + guide: `docs/plans/builder-relationship.md`, `docs/guide/builder.md` "Relationship-backed rows" section, `project_pilotiq_builder_relationship.md`. **`Repeater.afterCreate(fn) / afterUpdate(fn) / afterDelete(fn)` (2026-05-10 cont'd⁷):** per-row hooks fired inside `persistRelationshipRows` after each child operation. Handler receives `(record, ctx: RepeaterRowContext)` with `ctx = { parent, parentId, field, index, mode }`; `mode` is the resolved `RepeaterRelationMode` (`'hasMany' | 'morphMany' | 'belongsToMany' | 'morphToMany' | 'morphedByMany'`) so handlers can branch on M2M-detach-vs-physical-delete in `afterDelete`. `index` is `-1` on `afterDelete` (deleted rows aren't in the submitted set). Each setter throws at config time when `relationship()` hasn't been called first (config-time guard mirrors `orderColumn() / pivotColumns()`). Errors propagate — a throwing handler stops the rest of the persist diff; v1 isn't transactional so earlier rows are already committed. `afterUpdate` payload falls back to `{ ...existingByPk, ...payload }` when `model.update` returns void (rudder ORM's update typing — kept for ORMs that don't echo the updated record).
93
- - `src/entries/` — **Plan #16 (2026-05-04 cont'd)** read-only label-value pairs for `Resource.detail()`. **`RepeatableEntry` (2026-05-06 cont'd⁶)** — array-of-subschema sibling of `RepeaterField`. `RepeatableEntry.make(name).schema([Entry…]).columns(n).grid(n).table([cols]).contained(false)`. Reads the array off `ctx.record[name]` and resolves the inner schema once per row with `record` scoped to the row's data — inner entries (`TextEntry / BadgeEntry / IconEntry / ImageEntry / KeyValueEntry / ColorEntry`) read state via the same `record[childName]` lookup they use anywhere else. Three layout modes (renderer dispatches `table > grid > stack`); v1 takes a fixed integer for `grid()` (responsive object form deferred until asked). Primitive array elements stash under a reserved `_value` key so a `TextEntry.make('_value')` can target them. Empty / non-array / null falls through to the inherited `default()` placeholder. Resolution lives in `resolveSchema.ts → resolveRepeatableRows` next to the Repeater/Builder branches. `Entry` abstract base extends `Element` with the chrome surface (`label / inlineLabel / default / helperText / tooltip / weight / color / size / lineClamp / wrap / copyable / formatStateUsing`) plus the `Column`-mirroring formatter chain (`since / dateTime / money / numeric / limit`). Subclasses set `getEntryType()` for the wire-side discriminator. Seven leaves: `TextEntry` (default), `BadgeEntry.colors({ value: BadgeColor })`, `IconEntry.options({ value: { icon, color, label } })`, `ImageEntry.dimensions(px) / .square() / .rounded() / .circle() / .width(px) / .height(px)`, **`KeyValueEntry.keyLabel(t).valueLabel(t)` (2026-05-04 cont'd)** — read-only sibling of `KeyValueField`; renderer normalizes object | JSON-string | other into a `Record<string, unknown>` (non-JSON strings + arrays fall through to fallback) and renders a two-column kv table; nested values JSON-stringify for compactness; copyable serializes objects via `JSON.stringify`, **`ColorEntry.dimensions(px) / .square / .rounded / .circle / .hideValue()` (2026-05-04 cont'd)** — sibling of `ColorPickerField`; renders the raw CSS color string as a chip + the value text beside (toggle with `hideValue()` for chip-only). State resolves from `ctx.record[name]` at meta-build time — `Entry.toMeta(ctx)` is gated through the `el instanceof Field || el instanceof Filter || el instanceof Entry` branch in `resolveSchema.ts`. **`Entry.state(path | fn)` (2026-05-04 cont'd⁵):** override the default lookup when the value lives at a nested path or needs to be derived. String form does dotted-path traversal (`'author.name'`, `'tags.0.label'`); numeric segments index into arrays; missing intermediates resolve to `undefined`. Function form runs the user accessor with the full record; throwing fails soft (value=undefined). The entry's `name` still drives the auto-derived label and the discriminator key — pair `.state(...)` with `.label(...)` when the path doesn't read well as a heading. Composes with `formatStateUsing` (state resolves the value, formatter formats it). **`ComponentEntry` (2026-05-04 cont'd⁶, escape-hatch leaf):** hands rendering off to a user-supplied React component registered via `registerEntryComponents({ Name: Component })` from the `@pilotiq/pilotiq/entries` subpath (parallel to `@pilotiq/pilotiq/widgets` for `View`). Subclass form sets `static componentName = '...'`; fluent form uses `.component('...')`. Component receives `{ value }` props (where `value` is the resolved state via `.state(...)` or `record[name]`). For multi-field needs, pack via `.state(r => ({ … }))`. `entryType: 'component'`; renderer dispatches inside `renderEntry` and paints inline error panels (`<EntryComponentError>`) for missing-name / missing-registration. Render-time errors propagate to React's nearest error boundary (no per-entry boundary in v1). The renderer wraps the user component in `<EntryShell>` for label / helperText / tooltip / copyable parity with the typed leaves. Subpath `@pilotiq/pilotiq/entries` lives next to `/widgets` + `/uploads` for the same client-safe-import reason. Demo: `playground/app/Pilotiq/Posts/{ReadingStats.tsx, PostResource.detail()}` + registration in `pages/+Layout.tsx`. `formatStateUsing` runs server-side and stamps `_formatted` (renderer prefers it over re-applying `format`); throwing handlers fail soft. Renderer `react/SchemaRenderer.tsx → renderEntry` dispatches on `meta.entryType` (`'text' | 'badge' | 'icon' | 'image' | 'keyValue' | 'color'`) and wraps every leaf in `<EntryShell>` (label-above by default, label-left when `inlineLabel()`; copyable trigger and `?` info-tooltip both live in the shell). Wire-side `EntryMeta.type === 'entry'` so walkers branch via `isEntry(el)` rather than `instanceof`. Layout-level `visible / hidden / columnSpan` inherited from `Element`. Plan: `docs/plans/infolist-entries.md`, guide `docs/guide/infolists.md`. Demos: `playground/app/Pilotiq/Posts/PostResource.detail()` (Text / Badge / Icon), `playground/app/Pilotiq/Articles/ArticleResource.detail()` (KeyValue / Color against the `metadata` JSON-blob and `accentColor` columns).
94
- - `src/filters/` — `Filter` base + `SelectFilter / MultiSelectFilter / BooleanFilter / TernaryFilter / DateRangeFilter / TrashedFilter / FormFilter / QueryBuilderFilter`. Lives as children of `Table` via `.filters([...])`. URL keys; reserved keys are `search/sort/page/perPage`. `Filter.query(fn)` overrides default where-clause. **Filter expansion (2026-05-03):** `TernaryFilter` is 3-state (yes/no/blank) for nullable booleans — `.trueLabel/.falseLabel/.blankLabel/.nullable(false)`; default `_queryFn` set in `make()` emits `where(true)/where(false)/whereNull(name)` (falls back to `where(name,null)` when `whereNull?` is missing on the query — added as optional to `ModelQuery` parallel to Plan #13's `withTrashed?`). `DateRangeFilter` is a single URL key with `from..to`-encoded value (`?createdAt=2026-01-01..2026-12-31`, either side may be empty); `.includesTime/.minDate/.maxDate`; default handler emits `where(>=,from)`/`where(<=,to)` skipping the empty side; `parseDateRangeValue` / `encodeDateRangeValue` exported. Both filters set `_queryFn` inside `make()` (TrashedFilter pattern, NOT BooleanFilter's kind-branch pattern), so the model adapter's existing `if (customQuery)` branch covers them with no kind-specific changes. Renderer: `'ternary'` reuses `FilterSelect` with server-supplied options; `'dateRange'` is a new `FilterDateRange` component (two date / datetime-local inputs + Clear `×`, SPA-nav, `?page` reset on change). **Batch C (2026-05-02):** `Filter.indicator(string | (value, filter) => string)` overrides the active-filter pill text; default is `"<label>: <displayValue>"` where `displayValue` comes from each subclass's `formatActiveValue(value)` hook (Select/Multi → option labels, Boolean → Yes/No, Ternary → configured trueLabel/falseLabel/blankLabel, DateRange → `from → to` / `≥ from` / `≤ to`). `toMeta()` emits `indicator?: string` only when the filter has an active value. New `MultiSelectFilter` is a sibling of SelectFilter — `.options([...])`, comma-separated URL value (`?status=draft,published`), `kind: 'multiSelect'`, default `_queryFn` set in `make()` emits `where(name, 'IN', values)` (skips when the parsed list is empty). Helpers `parseMultiSelectValue / encodeMultiSelectValue` exported alongside the class. Renderer: new `<ActiveFiltersBar>` pill row above the table (× clears the URL key + resets `?page`; "Clear all" appears with 2+ active pills); new `FilterMultiSelect` checkbox stack inside the popover for `kind === 'multiSelect'`. **FormFilter (2026-05-04):** arbitrary-schema multi-field filter — `FormFilter.make(name).form([fields…]).handle((q, values) => q).formIndicator((values, filter) => string)`. URL value is JSON-encoded under the single filter key (`?amount={"min":100,"max":500}`); `parseFormFilterValue / encodeFormFilterValue` helpers exported. `toMeta(ctx)` is async, calls `resolveSchema(form, ctx)` to resolve inner fields (so dependent-options resolvers see the user / values context just like a regular form), then walks the resolved tree and stamps each field's `defaultValue` from the parsed URL payload so existing filters round-trip into the popover. `Filter.toMeta` widened to `(ctx?: RenderContext): FilterMeta | Promise<FilterMeta>` and `protected buildBaseMeta()` extracted so sibling sync subclasses (Select/Boolean/Ternary/DateRange/MultiSelect/Trashed) keep `super.toMeta()`-equivalent shape without spreading a Promise union. `resolveSchema` now passes `ctx` to `Filter` instances (mirroring the Field branch) so `FormFilter`'s inner schema resolves with the surrounding render context. `Filter.toMeta` default queryFn for `FormFilter` is a no-op `(q) => q` so modelDefaults' `if (customQuery)` short-circuits cleanly when the user hasn't called `.handle()` (otherwise the default `where(name, jsonString)` clause would fire with a JSON blob). Renderer: `kind === 'form'` mounts a `<FilterForm>` inside the existing `FilterPopover` — `<form>` ref + `new FormData(form)` collector + `encodeFormFilterValue` + Apply / Clear submit buttons. Field renderers reuse `renderFormChild` so every existing field type works inside a filter form (the popover is a small surface, so plain fields read best). **QueryBuilderFilter (2026-05-05 cont'd²¹):** Filament-style composable advanced filter — `QueryBuilderFilter.make(name).label().constraints([Constraint…])`. Constraints are typed value-objects (NOT Elements) — `TextConstraint / NumberConstraint / DateConstraint / SelectConstraint / BooleanConstraint`. Each declares `getOperators()` (per-operator label + `valueKind`) and `apply(query, operator, value): ModelQuery` (translates a single rule to ORM `where` clauses). URL value JSON-encodes into a single key `?runtime={"operator":"and","rules":[…]}`; tree-walker `applyTreeToQuery` chains every rule via `.where()` (AND-root only — see below). Indicator pill formats as `"Label: N condition(s)"` by default; `.treeIndicator(fn)` for override; `.handle((q, tree, filter) => …)` for custom dispatch (cross-table joins). Renderer `<FilterQueryBuilder>` widens `FilterPopover` to `w-[36rem]` when present; mounts a row-stack of (constraint Select + operator Select + value input dispatched off `valueKind`); local-state until Apply (mirrors FilterForm). v1 = AND-only / no nested groups / no relation traversal. **OR + nested groups blocked on rudder ORM `whereGroup` support** — see `~/Projects/rudder/docs/plans/where-group.md`. `notContains` operator on `TextConstraint` deferred (Prisma adapter doesn't translate `NOT LIKE`). Helpers `parseQueryBuilderValue / encodeQueryBuilderValue / emptyQueryBuilderTree / applyTreeToQuery` exported alongside the filter. Plan + guide: `docs/guide/query-builder.md`.
95
- - `src/actions/` — `Action` primitive. Placement: `inline | bulk | row | header`. Four mutually-exclusive dispatch modes: `Action.href(url) / .method(m).action(url) / .handler(ctx=>…) / .submit()`. Submit can target outside form via `.form(formId)` (HTML `form=` attribute). Submit can also attach a `name`/`value` pair via `.formField(name, value='1')` so the clicked button's pair lands in the form body — `FormRenderer.onSubmit` threads `event.submitter` into `new FormData(form, submitter)` to preserve it. Confirm-gated submits intentionally don't honor `formField` (programmatic `requestSubmit()` has no submitter). Handler actions get `dispatchUrl` stamped at render time.
96
-
97
- **Form-modal actions** (`Action.schema([Field, ...]).handler(...)` or `.modal*()` chrome) — trigger renders Dialog with schema as form. Submit fetches with `Accept: application/json`; server returns `{ ok, redirect, notifications }` (200) / `{ ok: false, errors }` (422) / `{ ok: false, error }` (500). **Form-method actions stay on 303-redirect form-post for back-compat** (Delete-row).
98
-
99
- **Variants & cosmetics**: `.color('primary'|'destructive'|'success'|'warning'|'info'|'ghost') / .size / .tooltip / .outlined / .iconButton / .badge / .badgeColor`. `.destructive()` is sugar.
100
-
101
- **Conditional visibility**: `.visible / .hidden / .disabled / .authorize` — rule is `boolean | (ctx) => boolean | Promise<boolean>` with `ActionVisibilityContext { record?, records?, user? }`. `Action.evaluate(ctx)` async; throws → `visible: false`. Non-row placements eval at schema-resolve. Row-placement actions defer to per-row eval in `loadTableRecords`, stamping `_visibleActions` / `_disabledActions`.
102
-
103
- - `src/actions/Action.ts` — `Action` class itself (~1,515 lines): types (`ActionContext / ActionResult / ActionHandler / ActionVisibilityContext / VisibilityRule / ResourceLike / ReplicateOptions` etc), instance fields + setters, modal chrome, link / form / submit mode wiring, `evaluate()` and `toMeta()`. Static factory methods stay on the class as thin delegators (`Action.create / .edit / .view / .delete / .replicate / .restore / .forceDelete / .markAsRead / .bulkDelete / .bulkRestore / .bulkForceDelete / .bulkReplicate / .relationCreate / .relationEdit / .relationDelete / .relationRestore / .relationForceDelete / .relationReplicate / .relationBulkReplicate / .relationAttach / .relationDetach / .relationBulkDetach`) — call sites stay stable; bodies live in the factory sub-modules. `Action.import / .export / .bulkExport` are the exceptions — their bodies stay in Action.ts since they already delegate their internals to the older `*Factory.ts` files via static + dynamic imports. Split via 4 phases (`docs/plans/action-split.md`, 2,159 → 1,515 lines).
104
- - `src/actions/factoryHelpers.ts` — Shared helpers consumed by every factory sub-module: `resourceBase(basePath, R)` (cluster-aware URL builder), `callPredicate(fn, user, record?)` (Plan #10 fail-open predicate caller), `isTrashed(record, R)` (soft-delete state probe), `isM2MMode(mode)` (true for `belongsToMany / morphToMany / morphedByMany`), `relationUrlPrefix(ctx)` (depth-1 + depth-2 relation URL prefix).
105
- - `src/actions/crudFactories.ts` — Single-row resource factories: `createAction / editAction / viewAction / deleteAction / replicateAction / restoreAction / forceDeleteAction / markAsReadAction`. Each builds an `Action` shaped by `ResourceLike`'s policy predicates + soft-delete flags.
106
- - `src/actions/bulkFactories.ts` — Bulk-placement factories: `bulkDeleteAction / bulkRestoreAction / bulkForceDeleteAction / bulkReplicateAction`. Handler-style — iterate `ctx.records`, run per-row policy, call matching model method, return notification with the count succeeded. Owns the phase-local `labelForCount(R, n)` helper for "1 post" / "5 posts" pluralization.
107
- - `src/actions/relationFactories.ts` — Relation hasMany / morph factories: `relationCreateAction / relationEditAction / relationDeleteAction / relationRestoreAction / relationForceDeleteAction` plus the replicate pair `relationReplicateAction / relationBulkReplicateAction`. Replicate helpers (`computeRelationPin / persistRelationReplica / runRelationReplicateRow`) are private to this file — they force-pin the parent attachment columns (`<foreignKey>` for hasMany; `<morphName>Id` + `<morphName>Type` for morphMany via `computeMorphPayload`) AFTER the strip + BEFORE `beforeReplicaSaved`, so a tampered source row can't slip a different parent in. All factories auto-hide under M2M (factories deferred to `m2mFactories.ts`) and under `morphTo` (child-side polymorphic — no single owner to pin to).
108
- - `src/actions/m2mFactories.ts` — Pivot factories: `relationAttachAction / relationDetachAction / relationBulkDetachAction`. All three auto-hide outside any M2M mode. `relationAttach` builds a modal-form `SelectField` picker via `attachFactory.buildAttachModalSchema` and POSTs the selected id to the manager's `_action/relationAttach` endpoint; `relationDetach` is a direct row-POST to `_detach/:childId`; `relationBulkDetach` runs the per-row `canDetach` gate inside the handler before calling `parent.related(rel).detach(ids)` once.
109
- - `src/actions/exportFactory.ts` — `Action.export / Action.bulkExport` internals (resolveExportColumns / collectExportRows / buildExportRow / encodeExport / defaultExportFilename). Lives in its own module so the Action ↔ Table cycle stays clean — `Action.export`'s handler dynamic-imports it at request time. Drives the table's existing `records()` handler in pages so filters / search / sort flow through unchanged. CSV body via `src/io/csv.ts` (RFC 4180 encoder + parser, in-memory only). Handler return shape extended with `{ download: { filename, contentType, body } }` on `ActionResult` + `DispatchActionSuccess`; route layer detects, sets `Content-Type` + `Content-Disposition: attachment` + `res.send(body)` (mutually exclusive with `redirect`); client `dispatchHandlerAction` content-sniffs `Content-Disposition` and triggers a synthetic `<a download>` blob download. Plan: `docs/plans/import-export-actions.md`.
110
- - `src/actions/importFactory.ts` — `Action.import` internals (parseImportText / runImport / buildImportSchema / buildImportNotification). Auto-builds the modal-form schema (FileUpload + a Mode select when `upsertBy` is set); handler reads `ctx.values.file`, fetches the URL the FileUpload stamped, parses CSV/JSON, walks rows through `R.model.create` (or `R.model.update` for matched upserts). Per-row `validate / beforeCreate / beforeUpdate` hooks; partial-failure-soft (rows that throw / fail validate accumulate in `summary.errors` and the import keeps going). Format auto-detected from filename when not set; `maxRows` cap (default 10_000). Visibility delegates to `R.canCreate`. No transaction wrapper in v1 (rudder ORM doesn't expose one to pilotiq yet).
111
- - `src/actions/attachFactory.ts` — `Action.relationAttach` modal-form internals: `buildAttachModalSchema({ Related, relationship, recordTitleAttr, labelSingular })` returns a `[SelectField]` populated by `loadAttachableCandidates(parent, rel, relatedModel, recordTitleAttr, limit?)` which fetches up to 50 candidate rows and filters out already-attached ids server-side. Mirrors the `importFactory.ts` / `exportFactory.ts` split — lives outside the Action class so M2M-specific imports (SelectField + ORM helpers) stay out of the main file.
112
- - `src/io/csv.ts` — `encodeCsv(rows, columns) / parseCsv(input)`. RFC 4180: CRLF emit, LF/CRLF accept, double-quote escaping for embedded quotes, BOM stripped on input. Every cell parsed as a string — type coercion is the importer's job. No third-party dep.
113
- - `src/actions/ActionGroup.ts` — `ActionGroup.make(name).label().icon().tooltip().actions([Action, ...])`. Renders as DropdownMenu. Same trigger styling + visibility rules as Action. Nested groups flatten. Bulk placement unsupported.
114
- - `src/schema/Heading.ts` — `Heading.actions([Action…])` — title left + actions right. Used by `CreatePage / EditPage` to put Save in header.
115
- - `src/validation/` — `Validator = (value, ctx?) => string | null | Promise<string | null>` + optional `serialized: SerializedRule` mirrored to `FieldMeta.rules`. Built-ins: `required, email, minLength, maxLength, min, max, pattern, unique` (async DB probe). `validateSchema(elements, values, record?) → Promise<{ name → string[] }>` — async-aware (awaits each field's validators in declaration order). `Field.required()` auto-contributes a check unless an explicit `required()` is present. `Field.runValidators` is `async` and returns `Promise<string[]>`. `unique({ model, column?, ignoreRecord=true, where?, caseInsensitive?, message? })` — uses `M.query().where(col, value).paginate(1, 2)` (no new ORM contract); `ignoreRecord` skips the row matching `ctx.record[primaryKey]` so edit-no-change saves don't conflict; `caseInsensitive` uses SQL `LIKE` with `%`/`_`/`\` escaped (SQLite/MySQL friendly, Postgres collation-dependent); thrown errors propagate (no silent fail-closed); inside a Repeater it probes the DB but does NOT see unsaved sibling rows. **`Field.distinct(opts?)` (2026-05-04 cont'd)** — cross-row uniqueness inside a Repeater / Builder. Stored on Field as `_distinct: DistinctOptions`; consumed by `validateRepeater` + `validateBuilder` (no-op outside an array-row context). Options: `caseInsensitive` (default false), `ignoreNulls` (default true — empty/null/'' rows are skipped), `message` (default `'Must be unique'`). First occurrence always passes; second + subsequent rows fail with the configured message under `<field>.<i>.<child>` (Repeater) or `<field>.<i>.data.<child>` (Builder). Builder comparison is per-block-type — `heading.text="X"` never conflicts with `paragraph.text="X"`. Pair with `unique({ model })` for in-form + cross-record uniqueness.
116
- - `src/notifications/` — `Notification.make(title).body().success/error/warning/info().icon().duration()`. Action handlers return `{ notify }`. `dispatchAction` normalizes to `NotificationMeta[]`. Form-post 303 path uses `@rudderjs/session` flash via duck-typed `req.session`; silently no-ops when absent. **Database-backed notifications (Filament v5 parity, 2026-05-06 cont'd¹²):** the same builder doubles as the entry-point for persistent notifications: `Notification.make(t).success().sendToDatabase(recipient)` writes a row on the `notification` table shipped by `@rudderjs/notification`. `Pilotiq.databaseNotifications(opts?)` opt-in toggle (default: off) mounts the bell-icon dropdown next to `<UserMenu>` + four endpoints: `GET ${base}/_notifications` (list + unreadCount), `POST ${base}/_notifications/:id/read` / `:id/unread` / `read-all`. Sugar setters `databaseNotificationsPolling(s|null)` / `databaseNotificationsPosition('topbar'|'sidebar')`. `panelInfo()` emits the `databaseNotifications` meta block (sparse — absent when not opted in OR no user resolves) carrying `position / polling / pageSize / badgeColor / trigger? / listUrl / readUrl / readAllUrl / unreadUrl`. Routes 401 when no user resolves, scope every read/write through `notifiable_id = String(user.id)`. Storage helper `notifications/database.ts` soft-imports `@rudderjs/orm` (variable-string dynamic import) so pilotiq stays zero-dep on orm — when no adapter is registered, list / count / mark functions return empty/zero/false; `persist()` throws (caller has explicitly asked to write). Wire shape `DatabaseNotificationMeta = { id, createdAt, readAt?, title, body?, type?, icon?, url? }`; reads JSON-decode the `data` column. Bell renderer `react/NotificationBell.tsx` is hand-built on `<DropdownMenu>` — fetch on mount + setInterval polling (paused while document hidden), latest-wins seq tracking on the list fetch, optimistic mark-read flips. **Broadcast (Phase 2, 2026-05-06 cont'd¹³):** `databaseNotificationsBroadcast(true | { wsUrl })` opt-in pushes new rows over WebSocket on `private-pilotiq-notifications.${user.id}` so the bell can refetch immediately instead of waiting for the next poll. `Notification.make(t).sendToDatabase(user, { broadcast: true })` chains the persist + push in one call; `Notification.make(t).broadcast(user)` pushes without persisting. `panelInfo()` ships `databaseNotifications.broadcast = { wsUrl, channel, event }` (sparse — absent when broadcast wasn't enabled or the user has no `id`). Routes registers a `private-pilotiq-notifications.*` auth callback at boot via `notifications/registerBroadcastAuth.ts`; the callback gates subscriptions on `pilotiq.resolveUser(req).id === channel.userId` so a tampered subscribe can't snoop another user's inbox. `@rudderjs/broadcast` is a runtime soft-import (variable-string dynamic import in both `notifications/broadcast.ts` and `registerBroadcastAuth.ts`); when not installed everything degrades to polling cleanly. Bell renderer subscribes via `RudderSocket` resolved from `window.__pilotiqRudderSocket` first, falling back to `@rudderjs/broadcast/client/RudderSocket.js`; failures (package missing / connect rejected / auth false) silently fall back to polling. Same-origin `wsUrl` (when meta.broadcast.wsUrl is empty) defaults to `${ws|wss}://${host}/ws`, matching the broadcast provider's default upgrade path. v1 limits: single notifiable type per panel (default `'users'`); no `Action.markAsRead()` factory inside notifications; no full-inbox modal page. Test seams: `_setTestAdapter(adp | null | undefined)` (orm), `_setTestBroadcast(mod | null | undefined)` (push), `_setTestBroadcastAuth(mod | null | undefined)` (auth registration). Tests in `notifications/database.test.ts`, `databaseNotifications.test.ts`, `broadcast.test.ts`, `registerBroadcastAuth.test.ts`. Plan + guide: `docs/guide/database-notifications.md` ("Broadcast (Phase 2)" section).
117
- - `src/theme/` — Theme engine: types, presets (default/nova/maia/lyra), base-colors, accent-colors, chart-palettes, radius, icon-map, `resolveTheme / generateThemeCSS`.
118
- - `src/react/AppShell.tsx` — Picks layout mode (sidebar / topbar). Wraps in `ToasterProvider` + `CommandPaletteProvider` (Plan #12 — palette open state lives here so the trigger pill in the layout header and the dialog share via context). `panel.navigation` from `panelInfo()`; `currentPath` from auto-gen `+Layout.tsx`.
119
- - `src/react/CommandPalette.tsx` — Plan #12 Cmd+K palette. Hand-rolled on Dialog primitive (no `cmdk` dep). Global Cmd/Ctrl+K listener; debounced fetch (150ms) to `${basePath}/_search` with in-flight seq cancellation (mirrors Plan #5 pattern). Empty input → flattens `panel.navigation` into a quick-nav list. Keyboard nav (↑/↓/Enter/Esc). Exports `useCommandPaletteOpener()` for triggers.
120
- - `src/react/SearchTrigger.tsx` — Plan #12 "Search… ⌘K" pill mounted inside SidebarLayout + TopbarLayout headers. macOS-aware shortcut hint. Renders nothing outside an AppShell (defensive — opener returns null without provider).
121
- - `src/react/Toaster.tsx` — `<ToasterProvider initialNotifications={...}>` + `useToast()`. Hand-built. Stack fixed bottom-right, auto-dismiss after 5s.
122
- - `src/react/ThemeProvider.tsx` — Light/dark/system context, localStorage, CSS var injection.
123
- - `src/react/ThemeToggle.tsx` — Sun/moon toggle.
124
- - `src/react/layouts/SidebarLayout.tsx` — shadcn Sidebar. `<SidebarGroup>` per nav group, `<SidebarMenuSub>` for children, `<SidebarMenuBadge>` for badges. Active = longest URL prefix.
125
- - `src/react/layouts/TopbarLayout.tsx` — horizontal nav variant. Group label → dropdown trigger.
126
- - `src/react/ThemeSettingsPage.tsx` — Theme editor: controls + live iframe preview.
127
- - `src/react/SchemaRenderer.tsx` — Thin top-level dispatcher (~550 lines) for resolved schema. Walks elements via `renderElement` and delegates each type to a sibling renderer under `src/react/schemaRenderer/`. Public surface: `<SchemaRenderer elements={...} widgetData={...} />` + the `FormFields` helper. Re-exports `dispatchHandlerAction` + `renderFormChild` for consumers (`RepeaterInput.tsx`, `SelectFieldInput.tsx`). Page-chrome renderers that don't fit a sub-namespace (`ListTabsRenderer`, `BreadcrumbsRenderer`, `RelationTabsRenderer`, `RelationTabIcon`) still live here.
128
- - `src/react/schemaRenderer/` — Modular renderer subtree. Split out of `SchemaRenderer.tsx` in 5 phases (PR #29–#33, 6,798 → 549 lines):
129
- - `constants.ts` — `TEXT_*` / `COLUMN_*` / `BADGE_COLOR_CLASSES` lookup tables + `alertStyles`.
130
- - `columnFormat.ts` — `applyColumnFormat` (dateTime / since / money / numeric / limit / words). Used by both table cells and entries.
131
- - `helpers.tsx` — `resolveIcon`, `layoutClasses`, `renderChildren`, `withTooltip`, `IconCmp` type.
132
- - `SimpleElements.tsx` — 13 stateless leaf + layout primitives (text/image/icon/markdown/html/heading/emptyState/divider/unorderedList/card/grid/group/split/fieldset). Exports `renderSimpleElement(el, index, deps)`; deps inject `renderElement` + `renderActionLike` for cases that recurse or mount action children.
133
- - `AlertRenderer.tsx` — dismissible alert + localStorage persist.
134
- - `SectionRenderer.tsx` — collapsible / persistable Section; takes `renderElement` prop for child recursion.
135
- - `TabsRenderer.tsx` — pills + underline variants.
136
- - `WizardRenderer.tsx` — multi-step form, URL/localStorage step persist, `WizardNavButton` chrome customizers.
137
- - `EntryRenderer.tsx` — `renderEntry` (8 entry types: text/badge/icon/image/keyValue/color/code/component/repeatable) + `EntryShell` chrome + tooltip/copy helpers.
138
- - `action/` — Phase 3 action layer:
139
- - `renderAction.tsx` — main switch (submit / href / method / handler / disabled placeholder) + `renderActionLike` (action / actionGroup / slotComponent).
140
- - `ActionModalDialog.tsx` / `ConfirmActionDialog.tsx` — modal + confirm dialogs.
141
- - `MethodActionButton.tsx` / `HandlerActionButton.tsx` — POST-style and JSON-dispatch buttons.
142
- - `ActionGroupTrigger.tsx` — dropdown trigger + pending-dialog state.
143
- - `buttons.tsx` — `actionButtonClass` / `renderActionIcon` / `renderActionBadge` + `COLOR_VARIANTS` / `OUTLINED_VARIANTS` / `SIZE_CLASSES` / `ICON_SIZE_CLASSES` + `RenderActionOptions` interface.
144
- - `helpers.ts` — `dispatchNotifications`, `dispatchMethodAction`, `dispatchHandlerAction`, attachment-download helpers + `Notify` / `Navigate` types.
145
- - `form/` — Phase 4 form layer:
146
- - `FormRenderer.tsx` — HTML form chrome + fetch+JSON submit + error stamping + `FormStateProvider` mount + `FormBody` / `renderFormChild` / `renderFieldWithValue`.
147
- - `renderField.tsx` — 13-way field-type switch + `TextFieldShell` (handles password reveal / mask / datalist via `useTextInputControls`).
148
- - `table/` — Phase 5 table layer:
149
- - `TableRenderer.tsx` — deferred-load wrapper + `TableSkeleton`.
150
- - `TableRendererBody.tsx` — the body itself. Column rendering / sort+pagination+search URL state / group banding+drill-in / per-row inline edit+delete / bulk-action toolbar / cards-layout dispatch / empty+loading states. Accepts a `TableBodyDeps` bag (`renderElement` + `renderActionLike` + `renderFormChild`).
151
- - `CardsLayoutBody.tsx` — `contentLayout('cards')` body + responsive `CARDS_GRID_COLS_*` lookup tables + `pickCardCols` / `cardsPerRowClasses`.
152
- - `filters.tsx` — every filter widget (`FilterSelect` / `FilterMultiSelect` / `FilterDateRange` / `FilterForm` / `FilterQueryBuilder` + query-builder sub-components) plus chrome (`FilterPopover` / `FilterStrip` / `FilterStripToggle` / `ActiveFiltersBar`) plus toolbar siblings (`TableGroupPicker` / `SortByPicker` / `ColumnsToggleDropdown` / `GroupHeaderText` / `renderFilterControl` / `resolveColumnUrl`).
153
- - `formatCell.tsx` — `formatCell` (8 column-type dispatch) + `wrapCell` / `wrapCellList` + `CellCopyButton` + `rowId`.
154
- - `url.tsx` — `TableUrlState` + `prefixK` / `buildTableQuery` / `nextSortDir` / `SearchFormHiddenInputs` / `getCurrentSearchParams`.
155
- - `links.tsx` — `RecordCellLink` (cell-level SPA link wrapper) + `ActiveGroupKeyChip` + `GroupHeadingLink`.
156
- - `renderRowActions.tsx` — per-row action strip; takes `renderActionLike` as a parameter so it stays free of the action import cycle.
157
- - **Dependency injection convention.** Renderers that need to recurse take `renderElement` (and sometimes `renderActionLike` / `renderFormChild`) as props or function arguments. `SchemaRenderer.tsx` binds them via closure with thin wrappers (`renderActionLike`, `renderField`, `renderFormChild`, `FormRenderer`, `TableRenderer`) so the rest of the file passes the closure-bound versions into the switch.
158
-
159
- **Row click navigation (table layer):** `Table.recordUrl(r => …)` stamps `_recordUrl`; each *data cell* wraps content in a real `<a href>` (`RecordCellLink`). Plain left-clicks SPA-nav via `useNavigate()`; modified clicks fall through. Action and bulk-select cells stay unwrapped. Per-column overrides: `Column.recordUrl(fn|false)`. `formatCell` switches on `col.columnType`, applies built-in `format` spec, reads `row._formatted[colName]` first. **Per-row classes:** `Table.recordClasses(r => '…')` stamps `_recordClasses` and the renderer concatenates onto the `<tr>` className (after striped + cursor-pointer). **Auto-refresh:** `Table.poll(seconds)` emits `pollInterval` on the meta; `TableRendererBody`'s `useEffect` runs `setInterval(navigate(pathname+search), n*1000)` and pauses while `document.visibilityState !== 'visible'`. **Group banding:** `Table.defaultGroup(col)` stable-sorts rows via `sortRowsByGroupValue` in `dispatchTable` (empty/null group values bubble to the bottom), stamps `_groupValue` per row; the renderer inserts a heading `<TableRow>` whenever the value changes between adjacent rows. **Footer summaries:** `Column.summarize([…])` makes `loadTableRecords` compute aggregates over the rendered rows; renderer mounts a `<TableFooter>` row aligned to the columns when `meta.summaries` is non-empty.
160
-
161
- **All non-modal action triggers SPA-update:** `dispatchHandlerAction / dispatchMethodAction` fetch with `Accept: application/json`, drain notifications via `useToast()`, then `useNavigate()` to redirect. Handler actions WITH confirm/modal → `ActionModalDialog` (controlled or uncontrolled). Confirm-only modals reuse same dialog.
162
-
163
- **Row-data convention** — server-side eval results land on each row under reserved keys: `_visibleActions, _disabledActions, _formatted, _recordUrl, _columnRecordUrls, _recordClasses, _groupValue`. `renderRowActions` filters `conditional: true` actions through `_visibleActions`.
164
-
165
- **Form submit dispatch (Save).** `FormRenderer` intercepts onSubmit, fetches with `Accept: application/json`. `FormData` is built with the submit `event.submitter` so `Action.formField()` name/value pairs ride the body (e.g. `_continueCreate=1` for "Create & create another"). On success: drain notifications, then `useNavigate(redirect)` — but skips when `redirect === current URL` so scroll preservation kicks in, UNLESS the response carries `force: true` (used by "create another" so the same `/create` URL still remounts a fresh form). On 422: parses errors into client state. On 5xx: error toast. Native form-post 303 path preserved as fallback. All POST handlers honor `Accept: application/json`.
166
-
167
- **Walkers use structural type checks, not `instanceof`.** `findForms / findActions` match on `el.getType() === 'form' | 'action'` — Vite SSR module-cache duplication breaks `instanceof`. See `feedback_vite_ssr_module_dup_instanceof.md`.
168
-
169
- **Plan #5 reactive fields (client).** `react/FormStateContext.tsx` ships `FormStateProvider` + `useFieldState(name)` + `useFormState()`. `FormRenderer` is dual-path: with `stateUrl` set it wraps children in the provider (controlled inputs); without it, falls back to today's uncontrolled `defaultValue` path. Each field renderer (`TextLikeInput` for text/email/number/textarea/slug; specialized inputs for toggle/select/date) consults `useFieldState` first, falls back to `defaultValue` when outside the provider — so non-live forms cost nothing. `triggerLive(name)` reads each field's `live` config and POSTs `{ changed, values }` to `stateUrl` (immediate / debounced via `setTimeout` / on blur). In-flight race handling via `requestSeqRef + latestSeenRef` (refs, not state — StrictMode trap). 422 → inline errors; other failures → toast. **Live forms must pin `Form.make().formId('stable-id')`** — see `feedback_pilotiq_live_forms_pin_formid.md`. Public exports: `FormStateProvider`, `useFieldState`, `useFormState` from `@pilotiq/pilotiq/react`.
170
- - `src/react/ui/` — shadcn primitives (sidebar, button, sheet, separator, tooltip, skeleton, input, checkbox, calendar, dialog, dropdown-menu, popover, select, switch, table, tabs, textarea, **slider** added in Plan #6).
171
- - `src/react/fields/` — extracted per-fieldtype renderers (`FieldShell`, `TextLikeInput`, `SelectFieldInput`, `ToggleFieldInput`, `DateFieldInput`, `HiddenInput`, `CheckboxInput`, `RadioInput`, `ToggleButtonsInput`, `CheckboxListInput`, `SliderInput`, `ColorInput`, `DateTimeInput`, `KeyValueInput`, `TagsInput`, `FileUploadInput`, `MarkdownInput`). `FieldShell` wraps every input with the label / required asterisk / prefix / suffix / helperText chrome — consumed by `schemaRenderer/form/renderField.tsx`'s 13-way field-type switch.
172
- - `src/uploads/` — `UploadAdapter` interface + `localUpload({ root, urlPrefix })` adapter for FileUpload. Apps register via `Pilotiq.uploads({ adapter })`; route `POST {base}/_uploads` (multipart) hands the file off to the adapter and returns `{ ok, url }`. `_uploads` route validates `accept` + `maxSize` server-side from the field meta (re-checked even though the client also enforces, so a tampered client can't bypass). Adapter contract is storage-agnostic — disk / S3 / R2 / `@pilotiq/media` all implement the same shape. **`RenderContext.uploadUrl` vs `hasUploadAdapter` (2026-05-04 cont'd):** `uploadUrl` is *always* stamped by `pageData.uploadCtx` (so `FileUpload` can show a clear "no upload route configured" error when missed); the companion `hasUploadAdapter: true` is only set when `cfg.uploads` is present, and tells fields with *optional* upload affordances (e.g. `MarkdownField`'s `attachFiles` toolbar button) to hide themselves rather than render a broken control. `uploadCtx(ctx, cfg)` takes the full PilotiqConfig now (was: `basePath`).
173
- - `src/plugins/themeEditor.ts` — `themeEditor()` plugin.
174
-
175
- ---
176
-
177
- ## Page generation (Vite plugin output)
178
-
179
- - `pages/(pilotiq)/+Head.tsx` — FOUC prevention script (reads localStorage, sets `.dark` before hydration) + Google Fonts preload
180
- - `pages/(pilotiq)/+Layout.tsx` — wraps pages in ThemeProvider + AppShell, injects theme CSS inline for SSR
181
- - `pages/(pilotiq)/+config.ts` — `passToClient: ['viewProps']`
182
- - `pages/(pilotiq)/dashboard/` — Dashboard (1-segment URL)
183
- - `pages/(pilotiq)/slug/` — **Single route** for 2-segment URLs (resource index, Global edit, custom page). Server sets `pageType: 'resource' | 'global' | 'page'` in viewProps; the renderer just renders `schemaData` uniformly via `<SchemaRenderer />`.
184
- - `pages/(pilotiq)/resource-create/` — 3-segment with `parts[2] === 'create'`
185
- - `pages/(pilotiq)/resource-view/` — 3-segment with `parts[2] !== 'create'` and `parts[1] !== 'theme'` (catches `${slug}/:id` for resource view AND `${global-slug}/view`)
186
- - `pages/(pilotiq)/resource-edit/` — 4-segment with `parts[3] === 'edit'`
187
- - `pages/(pilotiq)/relation-list/` — 4-segment relation list (`{slug}/:id/{rel}`); excludes `parts[3] === 'edit'` (resource-edit) and `parts[2] === 'create'` (resource-create)
188
- - `pages/(pilotiq)/relation-create/` — 5-segment with `parts[4] === 'create'`
189
- - `pages/(pilotiq)/relation-edit/` — 6-segment with `parts[5] === 'edit'`
190
- - `pages/(pilotiq)/theme/` — Theme editor page (only when `.use(themeEditor())`); slug route excludes `parts[1] === 'theme'`
191
- - Every `+Page.tsx` stub is just `<SchemaRenderer elements={vp.schemaData ?? []} />` — server resolves, client renders.
192
- - Route functions check `PilotiqRegistry` on server, tentatively match on client for SPA nav
193
-
194
- ---
195
-
196
- ## Plugin system
197
-
198
- - `PilotiqPlugin` interface: `{ name, register(panel), registerRoutes?(router, pilotiq) }`
199
- - `.use(plugin)` on builder — calls `plugin.register(panel)` immediately
200
- - `.plugins([p1, p2, …])` array sugar — `.use()`s each in order
201
- - **`registerRoutes?(router, pilotiq)` (optional)** — called by `registerPilotiqRoutes(router, pilotiq)` AFTER the core routes register. Plugins that own HTTP routes (chat endpoints, presence, custom REST) implement this hook so the host stops needing a separate `aiPlugin.mount(router, pilotiq)` two-step. Order matches registration; pilotiq's underscore-prefixed sibling-route convention (`_search`, `_uploads`, `_widget`, `_notifications`) is the recommended URL shape.
202
- - `@pilotiq/pilotiq/plugins` export path for built-in plugins (`themeEditor()`)
203
- - Adapter packages ship matching plugin factories alongside their `register*()` exports: `tiptap()` (`@pilotiq/tiptap`), `codeEditor({ languages? })` (`@pilotiq/codemirror`), `recharts()` (`@pilotiq/recharts`). The plugin form is the recommended setup — registers run through the panel module so it's wired in one place. `register*()` exports remain for direct use from `pages/+Layout.tsx` if desired. Both forms are idempotent (`Map.set` under the hood).
204
-
205
- ### Right sidebar (Phases A + B + C)
206
-
207
- - `Pilotiq.rightPanel({ id, label?, icon?, render, defaultWidth?, canAccess?, hidden?, sort? })` registers a single contribution; `Pilotiq.rightPanels([…])` is the bulk variant. Boot validates id shape `^[A-Za-z0-9_.-]+$`, dup ids, defaultWidth bounds, and that `render` is a component.
208
- - Server pipeline (Phase A): `panelInfo()` calls `buildRightSidebarMeta(cfg, user)` — runs `canAccess(user)` in parallel via `Promise.all`, drops `hidden:true`, sorts by `sort` ascending (registration order ties), serializes per-tab `RightPanelMeta { id, label, icon?, defaultWidth }`. Sparse: absent from `panelInfo()` when no contributions registered, every `canAccess` failed/threw, or every survivor was hidden.
209
- - Build-time wiring (Phase B): `_components.ts` Vite emitter walks `cfg.rightPanels` and stamps `rightPanelRegistry: { [id]: RenderComponent }` parallel to `componentRegistry`. `useRightPanelComponent(id)` resolves the body at mount time — render references never travel over the wire.
210
- - Chrome (Phase C): `RightSidebar.tsx` renders a fixed-position right rail on desktop with a 4 px left-edge resize handle, mobile Sheet fallback below `md`, tab strip auto-hidden when only one contribution is registered. `RightSidebarTrigger.tsx` mounts in the topbar between `<NotificationBell>` and `<UserMenu>` in both layouts; uses the contribution's icon when single, `PanelRightOpen/Close` lucide icons when multiple. Mod-Shift-\ (`code === 'Backslash'`) toggles open/close — owned by core, not pluggable.
211
- - Runtime state lives in `RightSidebarContext.tsx` — `useRightSidebar()` returns `{ open, setOpen, toggle, activeId, setActiveId, width, setWidth, contributions, bounds }`; `useRightSidebarOptional()` returns `null` outside the provider for chrome that wants to render conditionally. Persists three slots per `basePath`: `pilotiq.rightSidebar.<basePath>.{open,activeId,width}`. Width writes only on `pointerup` to avoid Storage churn.
212
- - Layout-flow shim: `AppShell` wraps the layout in a `<div>` with `paddingInlineEnd: width` when the panel is open on desktop (no padding on mobile or when closed). The wrapper element is stable across open/close so the Layout subtree never remounts. Real layout-column promotion deferred to v2.
213
- - Width bounds: `RIGHT_PANEL_MIN_WIDTH=240`, `RIGHT_PANEL_MAX_WIDTH=800`, `RIGHT_PANEL_DEFAULT_WIDTH=360`. Per-contribution `defaultWidth` clamps to the same range. `clampPanelWidth(value, opts?)` helper is exported from `@pilotiq/pilotiq/react` for tests + the Tiptap side-panel adopter (deferred).
214
- - Plugin authors expose factories: `panel.rightPanel(...)` from inside their `register(panel)` so consumers wire via `.plugins([…])`.
215
- - Plan: `docs/plans/right-sidebar.md`. Guide: `docs/guide/right-sidebar.md`.
216
-
217
- ### Component slots
218
-
219
- - `Pilotiq.components({ nav, header, footer })` registers build-time chrome-slot overrides. Three slots ship: `nav` (replaces default nav tree — `<SidebarMenu>` body in `SidebarLayout`, `<nav>` cluster in `TopbarLayout`), `header` (replaces the whole `<header>` chrome bar in both layouts — in `TopbarLayout` this subsumes the nav region, so setting `header` makes `nav` irrelevant for that layout), `footer` (mounts a `<footer>` element below the main content area in both layouts — separate from the `panels::footer` render hook, which keeps firing INSIDE the content area). The shape is open-ended so additions don't break this surface. `.components()` is order-independent and merges across calls (later call wins per slot; unset keys preserved).
220
- - Build-time wiring mirrors `rightPanels` — the Vite plugin's `_components.ts` emitter walks `cfg.components` and stamps `componentSlotRegistry: { [slot]: Component }` parallel to `componentRegistry` + `rightPanelRegistry`. The component ref never travels over the wire; the auto-gen `+Layout.tsx` imports the registry and forwards it to `<AppShell componentSlotRegistry=…>`, which threads it through to the layout via `AppShellProps`.
221
- - Render-hooks vs slots: render-hooks (`panels::sidebar.nav.start` / `panels::sidebar.nav.end`) *splice* around the nav region — they still fire whether the nav is the framework default OR a custom slot component, so plugins that just want to inject a header banner above the nav don't fight with consumers that swap the whole nav. **`header` is the exception** — render hooks rooted INSIDE the default header (`panels::topbar.start`, `panels::topbar.end`, `panels::user-menu.before`, `panels::user-menu.after`) do NOT fire when the header is replaced, since the container they splice into is gone. Plugin authors who care about preserving those splices inside a custom header can mount `<RenderHookSlot name="…" hooks={panel.renderHooks} />` themselves from within the custom header.
222
- - Public surface: `NavComponentProps`, `HeaderComponentProps`, `FooterComponentProps`, `isNavItemActive` re-exported from `@pilotiq/pilotiq/react`. `NavItem` is also re-exported there for convenience (it already exists on the main barrel). Prop contracts: `nav` + `header` both receive `{ navigation: NavItem[]; basePath: string; currentPath?: string }` (header mirrors nav so a topbar-replacement header can render the nav inline without juggling a second slot); `footer` receives `{ basePath: string; currentPath?: string }` (minimal — footers rarely need the nav tree). For `header` replacements, the existing pilotiq chrome components (`SearchTrigger`, `ThemeToggle`, `NotificationBell`, `RightSidebarTrigger`, `UserMenu`) are exported from `@pilotiq/pilotiq/react` so consumers can drop them back in à la carte rather than reimplementing every control. Authoring example: a user-defined `MyCustomSidebar` lives in `app/Pilotiq/` next to the panel module, gets registered via `Pilotiq.make('Admin').components({ nav: MyCustomSidebar })`.
223
- - **Authoring `.tsx` inside the panel module dir:** the Vite plugin jiti-loads the panel module at boot so it can harvest `cfg.components` into `_components.ts`. Two gotchas surfaced when this happens: (1) jiti needs JSX support enabled to parse `.tsx` — the plugin passes `jsx: { runtime: 'automatic' }` to `createJiti`, matching the playground tsconfig's `"jsx": "react-jsx"` (no per-file React import needed); (2) jiti's resolver falls through `.js` → `.ts` but NOT `.js` → `.tsx`, so the import statement in the panel module must use the literal `.tsx` extension (e.g. `import { MyCustomSidebar } from './MyCustomSidebar.tsx'`). `allowImportingTsExtensions: true` in the playground tsconfig keeps TS happy with this. The non-panel-module sibling pattern (a `.tsx` registered via `registerWidgetComponents` / `registerEntryComponents` from `+Layout.tsx`) sidesteps both — it's read by Vite, not jiti — but that pattern doesn't apply to component slots because the slot needs a real component ref at panel-build time, not a registry name.
224
-
225
- ---
226
-
227
- ## Theme system
228
-
229
- - `Pilotiq.theme({ preset, baseColor, accentColor, chartPalette, radius, fonts, iconLibrary, cssVariables })` configures theme
230
- - `resolveTheme()` layers: preset → base color → accent color → chart palette → raw CSS vars
231
- - `generateThemeCSS()` outputs `:root { ... } .dark { ... }` with `!important` for Tailwind override
232
- - ThemeProvider manages light/dark/system state, persists to `localStorage['pilotiq-theme']`
233
- - ThemeToggle renders in both SidebarLayout and TopbarLayout headers
234
- - FOUC prevention: inline `<script>` in +Head.tsx + inline `<style>` in +Layout.tsx
235
- - **Default preset: Pilotiq brand** — paper (white) page bg, cream (`oklch(0.979 0.008 78)`) sidebar, terracotta (`#d97757`) primary, ink (`#1a1a1a`) text, Satoshi font via Fontshare CDN. Matches the pilotiq.io marketing site tokens.
236
- - 4 presets (default, nova, maia, lyra), 7 base colors (`default` sentinel + 6 scales including `cream`), 17 accent colors (incl. `terracotta`), 6 chart palettes (incl. `terracotta`, `default` is a no-op sentinel), 5 radii
237
- - `resolveTheme()` fallbacks: body/heading font → `'Satoshi'`, radius → `'medium'` (10px)
238
- - All colors in OKLCH format for perceptual uniformity
239
- - `themeEditor` works without `.theme()` — the editor seeds an empty config so the built-in default preset + DB overrides still resolve; API routes mount on `hasThemeEditor()`, not on `getTheme()`
240
-
241
- **Fontshare for Satoshi:** the `+Head.tsx` font loader (and the theme editor preview iframe) detects `Satoshi` by name and loads it from `https://api.fontshare.com/v2/css?f[]=satoshi@300,500,700&display=swap`. Everything else falls back to Google Fonts. The loader reads from `resolved.fonts` (post-defaults) so Satoshi's stylesheet is always requested when it's the resolved heading or body font, even if the user only overrode the other side.
242
-
243
- **themeEditor() plugin:**
244
- - `import { themeEditor } from '@pilotiq/pilotiq/plugins'` → `.use(themeEditor())`
245
- - Adds "Theme" nav link in sidebar footer / topbar
246
- - ThemeSettingsPage: controls sidebar + live iframe preview (srcDoc, client-only via mounted guard)
247
- - API routes: GET/PUT/DELETE `{base}/api/_theme` persisted to `panelGlobal` table
248
- - `applyToParent()` updates `<style id="pilotiq-theme">` for instant visual feedback on save
249
- - Service provider loads saved overrides from DB on boot via `panel.setThemeOverrides()`
250
- - `getMergedTheme()` merges code defaults + DB overrides at runtime
251
- - Generated page passes `vike/client/router` `navigate` via `onNavigate` prop for server data re-fetch
252
- - `@pilotiq/pilotiq` must be in `optimizeDeps.exclude` in app's `vite.config.ts`
253
-
254
- ---
255
-
256
- ## Pilotiq-specific pitfalls
257
-
258
- - **Vike ignores gitignored pages:** NEVER add `pages/` subdirectories to `.gitignore`. Vike respects `.gitignore` when scanning — gitignored page directories are invisible to routing, causing silent 404s.
259
- - **Route functions + SPA nav:** Route functions in `pages/(pilotiq)/` must tentatively match on the client (`import.meta.env.SSR` check only gates the registry lookup, not the entire match). Returning `false` on client breaks SPA navigation and causes full page reloads.
260
- - **Page stubs are auto-generated:** `pages/(pilotiq)/` is regenerated by the `pilotiq()` Vite plugin. Don't edit manually — changes are overwritten. To customize rendering, create `app/Views/` files with matching `export const route` (static routes beat route functions).
261
- - **Layout persistence:** AppShell lives in `+Layout.tsx`, NOT in individual `+Page.tsx`. Vike keeps layouts mounted across navigations — putting the shell in pages causes sidebar to remount/reset on every nav.
262
- - **Stale `dist/`:** edits to `packages/pilotiq/src/**` require a rebuild to show up in the playground — run `pnpm -F @pilotiq/pilotiq build`, or `cd packages/pilotiq && pnpm dev` for watch mode.
263
- - **playground needs Tailwind:** `@pilotiq/pilotiq` ships components with Tailwind class names in `className`. The playground's `src/index.css` must `@import "tailwindcss"` with `@source "../../packages/pilotiq/src"` so Tailwind scans pilotiq's components. Without this, the UI renders unstyled.
264
- - **`optimizeDeps.exclude`:** `@pilotiq/pilotiq` must be in `optimizeDeps.exclude` in the app's `vite.config.ts` to avoid `node:fs` client errors during dev.
265
- - **Panel module must be client-safe:** `app/Pilotiq/AdminPanel.ts` is re-imported on the client via the Vite plugin's `_components.ts` manifest; Node-only side-effects (e.g. `localUpload(...)`) must move to `bootstrap/providers.ts`. `@pilotiq/pilotiq/uploads` is a separate subpath for this reason.
@@ -1,23 +0,0 @@
1
- import type { CollabRoom } from './CollabRoomContext.js';
2
- /**
3
- * Run `seedFn` exactly once after the collab room's first sync.
4
- *
5
- * Mirrors `@rudderjs/sync/react`'s `useCollabSeed` shape — same purpose,
6
- * reimplemented here so pilotiq core stays free of any hard runtime dep
7
- * on Yjs. The `room` parameter is pilotiq's opaque `CollabRoom`, so the
8
- * seed callback receives `doc: unknown` and is responsible for its own
9
- * share-type lookup (`doc.getXmlFragment(key)` for Tiptap consumers,
10
- * `doc.getText(key)` for CodeMirror, etc.) and its own emptiness check.
11
- *
12
- * Returns `true` once the seed callback has run (or was skipped because
13
- * the room has no `.synced` Promise — i.e. a legacy non-framework
14
- * provider), so consumers can gate editor mount / placeholder swap.
15
- *
16
- * The hook wraps `seedFn` in `doc.transact(..., 'pilotiq-collab-seed')`
17
- * so downstream observers can filter the synthetic write. Two peers
18
- * mounting against a brand-new record may both see "empty" and both
19
- * seed — same race window as the legacy `onProviderSynced` path; the
20
- * fix is server-side seed handoff, deferred.
21
- */
22
- export declare function useCollabSeed(room: CollabRoom | null, key: string, seedFn: (doc: unknown) => void): boolean;
23
- //# sourceMappingURL=useCollabSeed.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"useCollabSeed.d.ts","sourceRoot":"","sources":["../../src/react/useCollabSeed.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAA;AAIxD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAK,UAAU,GAAG,IAAI,EAC1B,GAAG,EAAM,MAAM,EACf,MAAM,EAAG,CAAC,GAAG,EAAE,OAAO,KAAK,IAAI,GAC9B,OAAO,CAwDT"}