@svgrid/enterprise 2.5.3 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (620) hide show
  1. package/dist/SvAlertRuleEditor.svelte.d.ts +13 -0
  2. package/dist/SvAlertsManager.svelte.d.ts +14 -0
  3. package/dist/SvAlertsPanel.svelte.d.ts +9 -0
  4. package/dist/SvAuthGate.svelte.d.ts +22 -0
  5. package/dist/SvBoard.svelte.d.ts +35 -0
  6. package/dist/SvExportMenu.svelte.d.ts +61 -0
  7. package/dist/SvExpressionEditor.svelte.d.ts +12 -0
  8. package/dist/SvFileInput.svelte.d.ts +20 -0
  9. package/dist/SvGridAlerts.svelte.d.ts +77 -0
  10. package/dist/SvGridBoard.svelte.d.ts +30 -0
  11. package/dist/SvGridEditPanel.svelte.d.ts +73 -0
  12. package/dist/SvGridMasterDetail.svelte.d.ts +49 -0
  13. package/dist/SvGridScheduler.svelte.d.ts +30 -0
  14. package/dist/SvImportDialog.svelte.d.ts +88 -0
  15. package/dist/SvLookupInput.svelte.d.ts +21 -0
  16. package/dist/SvPivotDesigner.svelte.d.ts +176 -0
  17. package/dist/SvRecordDetail.svelte.d.ts +58 -0
  18. package/dist/SvSchedule.svelte.d.ts +29 -0
  19. package/dist/SvSchemaChart.svelte.d.ts +61 -0
  20. package/dist/SvSchemaDashboard.svelte.d.ts +46 -0
  21. package/dist/alerts/alert-engine-attach.d.ts +79 -0
  22. package/dist/alerts/alert-engine-attach.js +106 -0
  23. package/dist/alerts/alert-engine.d.ts +42 -0
  24. package/dist/alerts/alert-engine.js +196 -0
  25. package/dist/alerts/alert-formats.d.ts +26 -0
  26. package/dist/alerts/alert-formats.js +58 -0
  27. package/dist/alerts/alert-observer.d.ts +54 -0
  28. package/dist/alerts/alert-observer.js +156 -0
  29. package/dist/alerts/alert-scheduler.d.ts +30 -0
  30. package/dist/alerts/alert-scheduler.js +54 -0
  31. package/dist/alerts/alert-storage.d.ts +31 -0
  32. package/dist/alerts/alert-storage.js +92 -0
  33. package/dist/alerts/alert-store.svelte.d.ts +24 -0
  34. package/dist/alerts/alert-store.svelte.js +69 -0
  35. package/dist/alerts/alert-types.d.ts +86 -0
  36. package/dist/alerts/alert-types.js +1 -0
  37. package/dist/alerts.d.ts +2 -0
  38. package/{src/alerts.ts → dist/alerts.js} +9 -9
  39. package/dist/board.d.ts +7 -0
  40. package/{src/board.ts → dist/board.js} +13 -14
  41. package/dist/cdn/svgrid-enterprise.svelte-external.js +1 -1
  42. package/dist/designer/assets/{index-CBYU-KNn.js → index-BF0UH638.js} +3 -3
  43. package/dist/designer/assets/{src-i309BeJb.js → src-BPJruwB9.js} +1 -1
  44. package/dist/designer/index.html +2 -2
  45. package/dist/edit-panel.d.ts +105 -0
  46. package/dist/edit-panel.js +350 -0
  47. package/dist/export-conditional.d.ts +31 -0
  48. package/dist/export-conditional.js +65 -0
  49. package/dist/export-ooxml.d.ts +93 -0
  50. package/dist/export-ooxml.js +385 -0
  51. package/dist/export-pdf.d.ts +107 -0
  52. package/dist/export-pdf.js +144 -0
  53. package/dist/export-print.d.ts +55 -0
  54. package/dist/export-print.js +91 -0
  55. package/{src/export-serialize.ts → dist/export-serialize.d.ts} +11 -24
  56. package/dist/export-serialize.js +11 -0
  57. package/dist/export-xls.d.ts +26 -0
  58. package/dist/export-xls.js +157 -0
  59. package/dist/export.d.ts +323 -0
  60. package/dist/export.js +1021 -0
  61. package/dist/expressions/evaluate.d.ts +17 -0
  62. package/dist/expressions/evaluate.js +214 -0
  63. package/dist/expressions/expression-columns.d.ts +61 -0
  64. package/dist/expressions/expression-columns.js +72 -0
  65. package/dist/expressions/expression-types.d.ts +125 -0
  66. package/dist/expressions/expression-types.js +1 -0
  67. package/dist/expressions/parse.d.ts +25 -0
  68. package/dist/expressions/parse.js +537 -0
  69. package/dist/import.d.ts +232 -0
  70. package/dist/import.js +846 -0
  71. package/dist/index.d.ts +66 -0
  72. package/dist/index.js +91 -0
  73. package/dist/install.d.ts +70 -0
  74. package/dist/install.js +61 -0
  75. package/dist/license-core.d.ts +37 -0
  76. package/dist/license-core.js +81 -0
  77. package/dist/license.d.ts +30 -0
  78. package/dist/license.js +139 -0
  79. package/dist/master-detail.d.ts +23 -0
  80. package/dist/master-detail.js +35 -0
  81. package/dist/node/studio.js +1 -1
  82. package/dist/pivot-chart.d.ts +28 -0
  83. package/dist/pivot-chart.js +71 -0
  84. package/dist/pivot-designer.d.ts +69 -0
  85. package/dist/pivot-designer.js +38 -0
  86. package/dist/pivot-enable.d.ts +6 -0
  87. package/{src/pivot-enable.ts → dist/pivot-enable.js} +13 -18
  88. package/dist/pivot.d.ts +111 -0
  89. package/dist/pivot.js +353 -0
  90. package/dist/print.d.ts +33 -0
  91. package/dist/print.js +61 -0
  92. package/dist/revoked.d.ts +1 -0
  93. package/{src/revoked.ts → dist/revoked.js} +5 -6
  94. package/dist/scheduler-assignments.d.ts +64 -0
  95. package/dist/scheduler-assignments.js +92 -0
  96. package/dist/scheduler-axis.d.ts +81 -0
  97. package/dist/scheduler-axis.js +180 -0
  98. package/{src/scheduler-booking.ts → dist/scheduler-booking.d.ts} +10 -43
  99. package/dist/scheduler-booking.js +50 -0
  100. package/dist/scheduler-config.d.ts +177 -0
  101. package/dist/scheduler-config.js +1 -0
  102. package/dist/scheduler-dependencies.d.ts +89 -0
  103. package/dist/scheduler-dependencies.js +179 -0
  104. package/dist/scheduler-freebusy.d.ts +19 -0
  105. package/{src/scheduler-freebusy.ts → dist/scheduler-freebusy.js} +9 -19
  106. package/{src/scheduler-heatmap.ts → dist/scheduler-heatmap.d.ts} +12 -35
  107. package/dist/scheduler-heatmap.js +41 -0
  108. package/dist/scheduler-resource-tree.d.ts +48 -0
  109. package/dist/scheduler-resource-tree.js +78 -0
  110. package/dist/scheduler-slots.d.ts +37 -0
  111. package/dist/scheduler-slots.js +80 -0
  112. package/dist/scheduler-summary.d.ts +41 -0
  113. package/dist/scheduler-summary.js +54 -0
  114. package/dist/scheduler.d.ts +7 -0
  115. package/{src/scheduler.ts → dist/scheduler.js} +13 -14
  116. package/dist/scheduling.d.ts +132 -0
  117. package/dist/scheduling.js +228 -0
  118. package/dist/schema-designer.d.ts +42 -0
  119. package/dist/schema-designer.js +117 -0
  120. package/dist/schema.d.ts +369 -0
  121. package/dist/schema.js +216 -0
  122. package/dist/smart-shim.d.ts +62 -0
  123. package/dist/smart-shim.js +34 -0
  124. package/dist/sources/aggregate.d.ts +43 -0
  125. package/dist/sources/aggregate.js +60 -0
  126. package/dist/sources/auth-supabase.d.ts +69 -0
  127. package/dist/sources/auth-supabase.js +39 -0
  128. package/dist/sources/dashboard.d.ts +65 -0
  129. package/dist/sources/dashboard.js +95 -0
  130. package/dist/sources/field-inference.d.ts +23 -0
  131. package/dist/sources/field-inference.js +52 -0
  132. package/dist/sources/filters.d.ts +35 -0
  133. package/dist/sources/filters.js +52 -0
  134. package/dist/sources/index.d.ts +18 -0
  135. package/dist/sources/index.js +18 -0
  136. package/dist/sources/introspect-supabase.d.ts +30 -0
  137. package/dist/sources/introspect-supabase.js +134 -0
  138. package/dist/sources/realtime-supabase.d.ts +37 -0
  139. package/dist/sources/realtime-supabase.js +52 -0
  140. package/dist/sources/relation-lookup.d.ts +50 -0
  141. package/dist/sources/relation-lookup.js +77 -0
  142. package/dist/sources/rest-adapters.d.ts +48 -0
  143. package/dist/sources/rest-adapters.js +85 -0
  144. package/dist/sources/rest.d.ts +38 -0
  145. package/dist/sources/rest.js +86 -0
  146. package/dist/sources/schema-from-columns.d.ts +37 -0
  147. package/dist/sources/schema-from-columns.js +48 -0
  148. package/dist/sources/supabase.d.ts +33 -0
  149. package/dist/sources/supabase.js +74 -0
  150. package/dist/sources/with-entity-rules.d.ts +25 -0
  151. package/dist/sources/with-entity-rules.js +47 -0
  152. package/dist/sources/with-relation-labels.d.ts +26 -0
  153. package/dist/sources/with-relation-labels.js +38 -0
  154. package/dist/staged-editing.d.ts +85 -0
  155. package/dist/staged-editing.js +0 -0
  156. package/dist/studio/bug-report.d.ts +61 -0
  157. package/dist/studio/bug-report.js +126 -0
  158. package/dist/studio/cli.d.ts +56 -0
  159. package/dist/studio/cli.js +70 -0
  160. package/dist/studio/copilot-core.d.ts +22 -0
  161. package/{src/studio/copilot-core.ts → dist/studio/copilot-core.js} +34 -38
  162. package/dist/studio/csv.d.ts +36 -0
  163. package/dist/studio/csv.js +173 -0
  164. package/dist/studio/db-connect-string.d.ts +45 -0
  165. package/dist/studio/db-connect-string.js +113 -0
  166. package/dist/studio/deploy-cli.d.ts +35 -0
  167. package/dist/studio/deploy-cli.js +81 -0
  168. package/dist/studio/emit-project.d.ts +77 -0
  169. package/dist/studio/emit-project.js +5389 -0
  170. package/dist/studio/emit-schema.d.ts +110 -0
  171. package/dist/studio/emit-schema.js +1128 -0
  172. package/dist/studio/index.d.ts +35 -0
  173. package/dist/studio/index.js +38 -0
  174. package/dist/studio/init-flow.d.ts +58 -0
  175. package/dist/studio/init-flow.js +451 -0
  176. package/dist/studio/introspect-db.d.ts +47 -0
  177. package/dist/studio/introspect-db.js +255 -0
  178. package/dist/studio/introspect-openapi.d.ts +19 -0
  179. package/dist/studio/introspect-openapi.js +244 -0
  180. package/dist/studio/introspect-prisma.d.ts +25 -0
  181. package/dist/studio/introspect-prisma.js +164 -0
  182. package/dist/studio/introspect.d.ts +35 -0
  183. package/dist/studio/introspect.js +306 -0
  184. package/dist/studio/project.d.ts +1327 -0
  185. package/dist/studio/project.js +1798 -0
  186. package/dist/studio/sample-data.d.ts +19 -0
  187. package/dist/studio/sample-data.js +207 -0
  188. package/dist/studio/samples/ats.d.ts +2 -0
  189. package/dist/studio/samples/ats.js +194 -0
  190. package/dist/studio/samples/clinic.d.ts +2 -0
  191. package/dist/studio/samples/clinic.js +171 -0
  192. package/dist/studio/samples/crm.d.ts +2 -0
  193. package/dist/studio/samples/crm.js +280 -0
  194. package/dist/studio/samples/datasets.d.ts +38 -0
  195. package/dist/studio/samples/datasets.js +296 -0
  196. package/dist/studio/samples/ecommerce.d.ts +2 -0
  197. package/dist/studio/samples/ecommerce.js +178 -0
  198. package/dist/studio/samples/events.d.ts +2 -0
  199. package/dist/studio/samples/events.js +192 -0
  200. package/dist/studio/samples/fleet.d.ts +2 -0
  201. package/dist/studio/samples/fleet.js +203 -0
  202. package/dist/studio/samples/gym.d.ts +2 -0
  203. package/dist/studio/samples/gym.js +205 -0
  204. package/dist/studio/samples/hr.d.ts +2 -0
  205. package/dist/studio/samples/hr.js +186 -0
  206. package/dist/studio/samples/index.d.ts +13 -0
  207. package/dist/studio/samples/index.js +44 -0
  208. package/dist/studio/samples/insurance.d.ts +2 -0
  209. package/dist/studio/samples/insurance.js +220 -0
  210. package/dist/studio/samples/inventory.d.ts +2 -0
  211. package/dist/studio/samples/inventory.js +204 -0
  212. package/dist/studio/samples/invoicing.d.ts +2 -0
  213. package/dist/studio/samples/invoicing.js +154 -0
  214. package/dist/studio/samples/library.d.ts +2 -0
  215. package/dist/studio/samples/library.js +171 -0
  216. package/dist/studio/samples/live-data.d.ts +5 -0
  217. package/dist/studio/samples/live-data.js +261 -0
  218. package/dist/studio/samples/projects.d.ts +2 -0
  219. package/dist/studio/samples/projects.js +203 -0
  220. package/dist/studio/samples/realestate.d.ts +2 -0
  221. package/dist/studio/samples/realestate.js +187 -0
  222. package/dist/studio/samples/restaurant.d.ts +2 -0
  223. package/dist/studio/samples/restaurant.js +180 -0
  224. package/dist/studio/samples/school.d.ts +2 -0
  225. package/dist/studio/samples/school.js +188 -0
  226. package/dist/studio/samples/shared.d.ts +142 -0
  227. package/dist/studio/samples/shared.js +164 -0
  228. package/dist/studio/samples/starter.d.ts +4 -0
  229. package/dist/studio/samples/starter.js +237 -0
  230. package/dist/studio/samples/subscriptions.d.ts +2 -0
  231. package/dist/studio/samples/subscriptions.js +175 -0
  232. package/dist/studio/samples/support.d.ts +2 -0
  233. package/dist/studio/samples/support.js +209 -0
  234. package/dist/studio/scaffold-app.d.ts +22 -0
  235. package/{src/studio/scaffold-app.ts → dist/studio/scaffold-app.js} +45 -63
  236. package/dist/studio/scaffold.d.ts +63 -0
  237. package/dist/studio/scaffold.js +346 -0
  238. package/dist/studio/screen-suites.d.ts +193 -0
  239. package/dist/studio/screen-suites.js +297 -0
  240. package/dist/studio/themes.d.ts +34 -0
  241. package/dist/studio/themes.js +41 -0
  242. package/dist/studio/ui-components.d.ts +89 -0
  243. package/dist/studio/ui-components.generated.d.ts +29 -0
  244. package/dist/studio/ui-components.generated.js +7139 -0
  245. package/dist/studio/ui-components.js +673 -0
  246. package/dist/studio/user-error.d.ts +23 -0
  247. package/{src/studio/user-error.ts → dist/studio/user-error.js} +10 -13
  248. package/dist/studio/verify.d.ts +34 -0
  249. package/dist/studio/verify.js +47 -0
  250. package/dist/sveltekit/in-memory.d.ts +14 -0
  251. package/dist/sveltekit/in-memory.js +113 -0
  252. package/dist/sveltekit/index.d.ts +6 -0
  253. package/dist/sveltekit/index.js +5 -0
  254. package/dist/sveltekit/query-plan.d.ts +44 -0
  255. package/dist/sveltekit/query-plan.js +76 -0
  256. package/dist/sveltekit/sql-source.d.ts +47 -0
  257. package/dist/sveltekit/sql-source.js +106 -0
  258. package/dist/sveltekit/sql.d.ts +48 -0
  259. package/dist/sveltekit/sql.js +73 -0
  260. package/dist/sveltekit/transport.d.ts +209 -0
  261. package/dist/sveltekit/transport.js +219 -0
  262. package/{src/sveltekit/types.ts → dist/sveltekit/types.d.ts} +8 -11
  263. package/dist/sveltekit/types.js +1 -0
  264. package/dist/upgrade-prompt.d.ts +16 -0
  265. package/dist/upgrade-prompt.js +141 -0
  266. package/dist/version.d.ts +13 -0
  267. package/{src/version.ts → dist/version.js} +1 -1
  268. package/dist/watermark.d.ts +2 -0
  269. package/dist/watermark.js +146 -0
  270. package/package.json +26 -24
  271. package/src/SvBoard.dom.test.ts +0 -67
  272. package/src/SvGridAlerts.dom.test.ts +0 -113
  273. package/src/SvGridEditPanel.dom.test.ts +0 -320
  274. package/src/SvRecordDetail.dom.test.ts +0 -137
  275. package/src/SvSchedule.dom.test.ts +0 -57
  276. package/src/ai-export-pdf.dom.test.ts +0 -77
  277. package/src/ai-export-xlsx.dom.test.ts +0 -90
  278. package/src/ai-export.dom.test.ts +0 -114
  279. package/src/alerts/alert-engine-attach.ts +0 -165
  280. package/src/alerts/alert-engine.test.ts +0 -135
  281. package/src/alerts/alert-engine.ts +0 -260
  282. package/src/alerts/alert-formats.test.ts +0 -87
  283. package/src/alerts/alert-formats.ts +0 -77
  284. package/src/alerts/alert-observer.test.ts +0 -189
  285. package/src/alerts/alert-observer.ts +0 -208
  286. package/src/alerts/alert-scheduler.ts +0 -96
  287. package/src/alerts/alert-storage.test.ts +0 -54
  288. package/src/alerts/alert-storage.ts +0 -116
  289. package/src/alerts/alert-store.svelte.ts +0 -80
  290. package/src/alerts/alert-types.ts +0 -94
  291. package/src/board.dom.test.ts +0 -941
  292. package/src/edit-panel.test.ts +0 -322
  293. package/src/edit-panel.ts +0 -379
  294. package/src/export-conditional.test.ts +0 -60
  295. package/src/export-conditional.ts +0 -94
  296. package/src/export-ooxml.test.ts +0 -152
  297. package/src/export-ooxml.ts +0 -489
  298. package/src/export-pdf.test.ts +0 -138
  299. package/src/export-pdf.ts +0 -245
  300. package/src/export-print.test.ts +0 -66
  301. package/src/export-print.ts +0 -132
  302. package/src/export-serialize.test.ts +0 -56
  303. package/src/export-xls.ts +0 -199
  304. package/src/export-xlsx-roundtrip.test.ts +0 -120
  305. package/src/export-xlsx.test.ts +0 -150
  306. package/src/export.test.ts +0 -349
  307. package/src/export.ts +0 -1464
  308. package/src/expressions/evaluate.test.ts +0 -111
  309. package/src/expressions/evaluate.ts +0 -237
  310. package/src/expressions/expression-columns.ts +0 -133
  311. package/src/expressions/expression-types.ts +0 -84
  312. package/src/expressions/parse.test.ts +0 -106
  313. package/src/expressions/parse.ts +0 -610
  314. package/src/import-automap.test.ts +0 -131
  315. package/src/import-hardening.test.ts +0 -158
  316. package/src/import.test.ts +0 -415
  317. package/src/import.ts +0 -1044
  318. package/src/index.ts +0 -569
  319. package/src/install.ts +0 -154
  320. package/src/license-core.test.ts +0 -23
  321. package/src/license-core.ts +0 -107
  322. package/src/license-expiry.test.ts +0 -144
  323. package/src/license.ts +0 -157
  324. package/src/master-detail.test.ts +0 -61
  325. package/src/master-detail.ts +0 -42
  326. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/_metadata.json +0 -178
  327. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/attachments-ZrPsKxob.js +0 -3875
  328. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/attachments-ZrPsKxob.js.map +0 -1
  329. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/constants-ACqvWGE2.js +0 -21
  330. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/constants-ACqvWGE2.js.map +0 -1
  331. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/dev-D3w5jt92.js +0 -1938
  332. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/dev-D3w5jt92.js.map +0 -1
  333. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/events-CC2V3H0y.js +0 -205
  334. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/events-CC2V3H0y.js.map +0 -1
  335. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/flags-BXP37tAa.js +0 -18
  336. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/flags-BXP37tAa.js.map +0 -1
  337. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/hydration-DwubnU5b.js +0 -264
  338. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/hydration-DwubnU5b.js.map +0 -1
  339. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/legacy-client-hD7f6PUz.js +0 -697
  340. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/legacy-client-hD7f6PUz.js.map +0 -1
  341. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte.js +0 -134
  342. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte.js.map +0 -1
  343. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_animate.js +0 -55
  344. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_animate.js.map +0 -1
  345. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_attachments.js +0 -2
  346. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_compiler.js +0 -46843
  347. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_compiler.js.map +0 -1
  348. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_easing.js +0 -237
  349. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_easing.js.map +0 -1
  350. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_events.js +0 -2
  351. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal.js +0 -4
  352. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal.js.map +0 -1
  353. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_client.js +0 -7
  354. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_disclose-version.js +0 -4
  355. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_disclose-version.js.map +0 -1
  356. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_async.js +0 -5
  357. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_async.js.map +0 -1
  358. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_legacy.js +0 -5
  359. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_legacy.js.map +0 -1
  360. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_tracing.js +0 -5
  361. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_tracing.js.map +0 -1
  362. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_server.js +0 -6
  363. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_legacy.js +0 -83
  364. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_legacy.js.map +0 -1
  365. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_motion.js +0 -559
  366. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_motion.js.map +0 -1
  367. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity.js +0 -24
  368. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity.js.map +0 -1
  369. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity_window.js +0 -122
  370. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity_window.js.map +0 -1
  371. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_server.js +0 -2
  372. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_store.js +0 -89
  373. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_store.js.map +0 -1
  374. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_transition.js +0 -241
  375. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_transition.js.map +0 -1
  376. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-DBx1SCN6.js +0 -4938
  377. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-DBx1SCN6.js.map +0 -1
  378. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-gjfpSTFS.js +0 -495
  379. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-gjfpSTFS.js.map +0 -1
  380. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/validate-DKoOKg8S.js +0 -190
  381. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/validate-DKoOKg8S.js.map +0 -1
  382. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/version-C0MiR7eR.js +0 -8
  383. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/version-C0MiR7eR.js.map +0 -1
  384. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/warnings-B6Y7P0Q-.js +0 -138
  385. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/warnings-B6Y7P0Q-.js.map +0 -1
  386. package/src/pivot-chart.test.ts +0 -86
  387. package/src/pivot-chart.ts +0 -112
  388. package/src/pivot-designer.ts +0 -101
  389. package/src/pivot.test.ts +0 -335
  390. package/src/pivot.ts +0 -578
  391. package/src/print.ts +0 -107
  392. package/src/scheduler-assignments.test.ts +0 -97
  393. package/src/scheduler-assignments.ts +0 -134
  394. package/src/scheduler-axis.test.ts +0 -108
  395. package/src/scheduler-axis.ts +0 -238
  396. package/src/scheduler-booking.test.ts +0 -57
  397. package/src/scheduler-config.ts +0 -179
  398. package/src/scheduler-dependencies.test.ts +0 -155
  399. package/src/scheduler-dependencies.ts +0 -223
  400. package/src/scheduler-freebusy.test.ts +0 -41
  401. package/src/scheduler-heatmap.test.ts +0 -39
  402. package/src/scheduler-resource-tree.test.ts +0 -84
  403. package/src/scheduler-resource-tree.ts +0 -107
  404. package/src/scheduler-slots.test.ts +0 -53
  405. package/src/scheduler-slots.ts +0 -94
  406. package/src/scheduler-summary.test.ts +0 -47
  407. package/src/scheduler-summary.ts +0 -81
  408. package/src/scheduling.test.ts +0 -194
  409. package/src/scheduling.ts +0 -293
  410. package/src/schema-designer.test.ts +0 -121
  411. package/src/schema-designer.ts +0 -142
  412. package/src/schema.test.ts +0 -268
  413. package/src/schema.ts +0 -562
  414. package/src/smart-shim.ts +0 -107
  415. package/src/sources/aggregate.test.ts +0 -79
  416. package/src/sources/aggregate.ts +0 -102
  417. package/src/sources/auth-supabase.test.ts +0 -80
  418. package/src/sources/auth-supabase.ts +0 -87
  419. package/src/sources/dashboard.kpi.test.ts +0 -50
  420. package/src/sources/dashboard.test.ts +0 -61
  421. package/src/sources/dashboard.ts +0 -136
  422. package/src/sources/field-inference.test.ts +0 -60
  423. package/src/sources/field-inference.ts +0 -63
  424. package/src/sources/filters.test.ts +0 -58
  425. package/src/sources/filters.ts +0 -70
  426. package/src/sources/index.ts +0 -67
  427. package/src/sources/introspect-supabase.test.ts +0 -112
  428. package/src/sources/introspect-supabase.ts +0 -139
  429. package/src/sources/realtime-supabase.test.ts +0 -93
  430. package/src/sources/realtime-supabase.ts +0 -99
  431. package/src/sources/relation-lookup.test.ts +0 -99
  432. package/src/sources/relation-lookup.ts +0 -128
  433. package/src/sources/rest-adapters.test.ts +0 -95
  434. package/src/sources/rest-adapters.ts +0 -120
  435. package/src/sources/rest.test.ts +0 -104
  436. package/src/sources/rest.ts +0 -129
  437. package/src/sources/schema-from-columns.test.ts +0 -106
  438. package/src/sources/schema-from-columns.ts +0 -88
  439. package/src/sources/supabase.test.ts +0 -110
  440. package/src/sources/supabase.ts +0 -99
  441. package/src/sources/with-entity-rules.test.ts +0 -104
  442. package/src/sources/with-entity-rules.ts +0 -78
  443. package/src/sources/with-relation-labels.test.ts +0 -75
  444. package/src/sources/with-relation-labels.ts +0 -73
  445. package/src/staged-editing.ts +0 -0
  446. package/src/studio/bug-report.test.ts +0 -101
  447. package/src/studio/bug-report.ts +0 -165
  448. package/src/studio/cli.test.ts +0 -187
  449. package/src/studio/cli.ts +0 -117
  450. package/src/studio/copilot-core.test.ts +0 -45
  451. package/src/studio/csv.test.ts +0 -90
  452. package/src/studio/csv.ts +0 -165
  453. package/src/studio/db-connect-string.test.ts +0 -94
  454. package/src/studio/db-connect-string.ts +0 -128
  455. package/src/studio/deploy-cli.test.ts +0 -56
  456. package/src/studio/deploy-cli.ts +0 -100
  457. package/src/studio/emit-project.test.ts +0 -3324
  458. package/src/studio/emit-project.ts +0 -5377
  459. package/src/studio/emit-schema.test.ts +0 -111
  460. package/src/studio/emit-schema.ts +0 -1181
  461. package/src/studio/index.ts +0 -380
  462. package/src/studio/init-flow.test.ts +0 -514
  463. package/src/studio/init-flow.ts +0 -556
  464. package/src/studio/introspect-db.test.ts +0 -186
  465. package/src/studio/introspect-db.ts +0 -312
  466. package/src/studio/introspect-openapi.test.ts +0 -84
  467. package/src/studio/introspect-openapi.ts +0 -252
  468. package/src/studio/introspect-prisma.test.ts +0 -99
  469. package/src/studio/introspect-prisma.ts +0 -175
  470. package/src/studio/introspect.test.ts +0 -172
  471. package/src/studio/introspect.ts +0 -310
  472. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/_svelte_metadata.json +0 -1
  473. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/package.json +0 -3
  474. package/src/studio/pipeline.test.ts +0 -42
  475. package/src/studio/project-robust.test.ts +0 -157
  476. package/src/studio/project.test.ts +0 -929
  477. package/src/studio/project.ts +0 -2394
  478. package/src/studio/sample-data.test.ts +0 -58
  479. package/src/studio/sample-data.ts +0 -203
  480. package/src/studio/samples/ats.ts +0 -202
  481. package/src/studio/samples/clinic.ts +0 -179
  482. package/src/studio/samples/crm.ts +0 -291
  483. package/src/studio/samples/datasets.test.ts +0 -84
  484. package/src/studio/samples/datasets.ts +0 -340
  485. package/src/studio/samples/ecommerce.ts +0 -187
  486. package/src/studio/samples/events.ts +0 -201
  487. package/src/studio/samples/fleet.ts +0 -213
  488. package/src/studio/samples/gym.ts +0 -215
  489. package/src/studio/samples/hr.ts +0 -195
  490. package/src/studio/samples/index.ts +0 -55
  491. package/src/studio/samples/insurance.ts +0 -229
  492. package/src/studio/samples/inventory.ts +0 -213
  493. package/src/studio/samples/invoicing.ts +0 -162
  494. package/src/studio/samples/library.ts +0 -180
  495. package/src/studio/samples/live-data.test.ts +0 -99
  496. package/src/studio/samples/live-data.ts +0 -306
  497. package/src/studio/samples/projects.ts +0 -212
  498. package/src/studio/samples/realestate.ts +0 -196
  499. package/src/studio/samples/restaurant.ts +0 -189
  500. package/src/studio/samples/samples.test.ts +0 -304
  501. package/src/studio/samples/school.ts +0 -197
  502. package/src/studio/samples/seed-floor.test.ts +0 -25
  503. package/src/studio/samples/shared.ts +0 -237
  504. package/src/studio/samples/starter.ts +0 -251
  505. package/src/studio/samples/subscriptions.ts +0 -183
  506. package/src/studio/samples/support.ts +0 -216
  507. package/src/studio/scaffold-app.test.ts +0 -91
  508. package/src/studio/scaffold.test.ts +0 -192
  509. package/src/studio/scaffold.ts +0 -390
  510. package/src/studio/screen-suites.test.ts +0 -269
  511. package/src/studio/screen-suites.ts +0 -448
  512. package/src/studio/themes.ts +0 -62
  513. package/src/studio/ui-components-surface.test.ts +0 -185
  514. package/src/studio/ui-components.generated.ts +0 -7154
  515. package/src/studio/ui-components.ts +0 -742
  516. package/src/studio/verify.test.ts +0 -47
  517. package/src/studio/verify.ts +0 -79
  518. package/src/sveltekit/in-memory.test.ts +0 -104
  519. package/src/sveltekit/in-memory.ts +0 -129
  520. package/src/sveltekit/index.ts +0 -24
  521. package/src/sveltekit/query-plan.test.ts +0 -109
  522. package/src/sveltekit/query-plan.ts +0 -125
  523. package/src/sveltekit/sql-source.test.ts +0 -198
  524. package/src/sveltekit/sql-source.ts +0 -171
  525. package/src/sveltekit/sql.test.ts +0 -80
  526. package/src/sveltekit/sql.ts +0 -131
  527. package/src/sveltekit/transport-scope.test.ts +0 -125
  528. package/src/sveltekit/transport.test.ts +0 -201
  529. package/src/sveltekit/transport.ts +0 -373
  530. package/src/upgrade-prompt.test.ts +0 -71
  531. package/src/upgrade-prompt.ts +0 -162
  532. package/src/version.test.ts +0 -17
  533. package/src/watermark.test.ts +0 -97
  534. package/src/watermark.ts +0 -156
  535. /package/{src → dist}/SvAlertRuleEditor.svelte +0 -0
  536. /package/{src → dist}/SvAlertsManager.svelte +0 -0
  537. /package/{src → dist}/SvAlertsPanel.svelte +0 -0
  538. /package/{src → dist}/SvAuthGate.svelte +0 -0
  539. /package/{src → dist}/SvBoard.svelte +0 -0
  540. /package/{src → dist}/SvExportMenu.svelte +0 -0
  541. /package/{src → dist}/SvExpressionEditor.svelte +0 -0
  542. /package/{src → dist}/SvFileInput.svelte +0 -0
  543. /package/{src → dist}/SvGridAlerts.svelte +0 -0
  544. /package/{src → dist}/SvGridBoard.svelte +0 -0
  545. /package/{src → dist}/SvGridEditPanel.svelte +0 -0
  546. /package/{src → dist}/SvGridMasterDetail.svelte +0 -0
  547. /package/{src → dist}/SvGridScheduler.svelte +0 -0
  548. /package/{src → dist}/SvImportDialog.svelte +0 -0
  549. /package/{src → dist}/SvLookupInput.svelte +0 -0
  550. /package/{src → dist}/SvPivotDesigner.svelte +0 -0
  551. /package/{src → dist}/SvRecordDetail.svelte +0 -0
  552. /package/{src → dist}/SvSchedule.svelte +0 -0
  553. /package/{src → dist}/SvSchemaChart.svelte +0 -0
  554. /package/{src → dist}/SvSchemaDashboard.svelte +0 -0
  555. /package/{src → dist}/pdfmake-shims.d.ts +0 -0
  556. /package/{src → dist}/smart.export.d.ts +0 -0
  557. /package/{src → dist}/smart.export.js +0 -0
  558. /package/{src → dist}/studio/HANDLERS-DESIGN.md +0 -0
  559. /package/{src → dist/studio}/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/_svelte_metadata.json +0 -0
  560. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/_metadata.json +0 -0
  561. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/attachments-BOv1rBKp.js +0 -0
  562. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/attachments-BOv1rBKp.js.map +0 -0
  563. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/constants-B9vgIdCd.js +0 -0
  564. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/constants-B9vgIdCd.js.map +0 -0
  565. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/dev-T1qbW_qD.js +0 -0
  566. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/dev-T1qbW_qD.js.map +0 -0
  567. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/events-Bo11SfVK.js +0 -0
  568. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/events-Bo11SfVK.js.map +0 -0
  569. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/flags-X7D4vfrZ.js +0 -0
  570. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/flags-X7D4vfrZ.js.map +0 -0
  571. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/hydration-oyUje5i1.js +0 -0
  572. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/hydration-oyUje5i1.js.map +0 -0
  573. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/legacy-client-DeSQgSFm.js +0 -0
  574. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/legacy-client-DeSQgSFm.js.map +0 -0
  575. /package/{src → dist/studio}/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/package.json +0 -0
  576. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte.js +0 -0
  577. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte.js.map +0 -0
  578. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_animate.js +0 -0
  579. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_animate.js.map +0 -0
  580. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_attachments.js +0 -0
  581. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_compiler.js +0 -0
  582. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_compiler.js.map +0 -0
  583. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_easing.js +0 -0
  584. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_easing.js.map +0 -0
  585. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_events.js +0 -0
  586. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal.js +0 -0
  587. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal.js.map +0 -0
  588. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_client.js +0 -0
  589. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_disclose-version.js +0 -0
  590. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_disclose-version.js.map +0 -0
  591. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_async.js +0 -0
  592. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_async.js.map +0 -0
  593. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_legacy.js +0 -0
  594. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_legacy.js.map +0 -0
  595. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_tracing.js +0 -0
  596. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_tracing.js.map +0 -0
  597. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_server.js +0 -0
  598. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_legacy.js +0 -0
  599. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_legacy.js.map +0 -0
  600. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_motion.js +0 -0
  601. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_motion.js.map +0 -0
  602. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity.js +0 -0
  603. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity.js.map +0 -0
  604. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity_window.js +0 -0
  605. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity_window.js.map +0 -0
  606. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_server.js +0 -0
  607. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_store.js +0 -0
  608. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_store.js.map +0 -0
  609. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_transition.js +0 -0
  610. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_transition.js.map +0 -0
  611. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-BbWyXNk1.js +0 -0
  612. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-BbWyXNk1.js.map +0 -0
  613. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-DI_fC7vt.js +0 -0
  614. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-DI_fC7vt.js.map +0 -0
  615. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/validate-Dyr3lQv9.js +0 -0
  616. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/validate-Dyr3lQv9.js.map +0 -0
  617. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/version-_9FHH-LF.js +0 -0
  618. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/version-_9FHH-LF.js.map +0 -0
  619. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/warnings-CUbSPnyP.js +0 -0
  620. /package/{src → dist}/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/warnings-CUbSPnyP.js.map +0 -0
@@ -1,2394 +0,0 @@
1
- /**
2
- * Studio project model - the declarative spine the visual designer edits and the
3
- * codegen emits. A project is a multi-entity app: `entities` (the data model) +
4
- * `screens` (one per entity by default), where a screen is an ordered list of
5
- * data-bound `blocks` (grid, form, chart, dashboard, KPI, master-detail, lookup).
6
- *
7
- * Pure + node-safe (lives in the `./studio` subtree - `.js` imports, no Svelte,
8
- * no `sources/`), mirroring the immutable-ops style of `schema-designer.ts`:
9
- * every mutation returns a NEW project so Svelte reactivity + undo stay simple.
10
- * Block-config unions are defined here (not imported from `sources/`) to keep the
11
- * module resolvable under node16.
12
- */
13
- import type { EntityField, EntityFieldType, EntitySchema, FormSection } from '../schema.js'
14
- import type { ChartType, DockManagerState, DockNode, DockPane, DockTabs } from '@svgrid/grid'
15
- import type { PredicateExpr } from '../expressions/expression-types.js'
16
- import { collectColumnRefs } from '../expressions/parse.js'
17
- import { uiComponentSpec } from './ui-components.js'
18
-
19
- export type Reduce = 'sum' | 'avg' | 'count' | 'min' | 'max'
20
- export type DataSourceKind = 'memory' | 'sql' | 'supabase' | 'rest' | 'pglite'
21
- export type Presentation = 'modal' | 'drawer' | 'inline'
22
-
23
- // --- per-entity data-source binding ----------------------------------------
24
- // Each entity can bind to its own backend (a REST endpoint, a SQL table, a
25
- // Supabase table, or the seeded in-memory store). Connection config is a
26
- // design-time concern, so it lives on the project (keyed by entity name), NOT on
27
- // the pure EntitySchema. The codegen branches on the resolved source per entity.
28
-
29
- export type RestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
30
- export type ParamLocation = 'path' | 'query' | 'header'
31
- export type ParamType = 'string' | 'number' | 'boolean'
32
- /** One request parameter in the REST builder (path `{id}`, query, or header). */
33
- export type RequestParam = { name: string; location: ParamLocation; type: ParamType; value?: string }
34
- export type SqlDialectKind = 'postgres' | 'mysql' | 'sqlite' | 'mssql' | 'supabase' | 'turso'
35
-
36
- /** In-memory (seeded) source. `seed` carries curated rows (e.g. a sample app);
37
- * when absent the codegen + preview synthesize realistic rows. */
38
- export type MemorySource = { kind: 'memory'; seed?: Record<string, unknown>[] }
39
- /** Wire-format preset that teaches the REST source how a backend pages / sorts /
40
- * wraps its rows, so the generated grid does REAL server-side paging + sort (not a
41
- * single fetched page). Maps to the `offsetLimit` / `dummyjson` / `jsonServer`
42
- * adapters in `@svgrid/enterprise`. Absent = the legacy manual `rowsPath`/`totalPath`
43
- * (parse only; no server paging). */
44
- export type RestAdapterConfig =
45
- | { kind: 'dummyjson'; rowsKey?: string }
46
- | { kind: 'jsonServer' }
47
- | {
48
- kind: 'offsetLimit'
49
- offsetParam?: string
50
- limitParam?: string
51
- sortByParam?: string
52
- orderParam?: string
53
- searchParam?: string
54
- rowsKey?: string
55
- totalKey?: string
56
- }
57
- export type RestSource = {
58
- kind: 'rest'
59
- /** Origin + version prefix, e.g. `https://api.example.com/v1`. */
60
- baseUrl: string
61
- /** Collection path, e.g. `customers` or `albums/{id}/tracks`. */
62
- path: string
63
- method: RestMethod
64
- params: RequestParam[]
65
- idField?: string
66
- /** Wire-format preset for real server paging/sort (offsetLimit / dummyjson /
67
- * jsonServer). When set, it supersedes `rowsPath`/`totalPath`. */
68
- adapter?: RestAdapterConfig
69
- /** Dotted path in the response body holding the rows, e.g. `data.items`. */
70
- rowsPath?: string
71
- /** Dotted path holding the total row count, e.g. `data.total`. */
72
- totalPath?: string
73
- }
74
- export type SqlSource = { kind: 'sql'; table: string; dialect?: SqlDialectKind; /** DB schema / search path (Postgres/MSSQL); default `public`. Qualifies the table. */ schema?: string }
75
- export type SupabaseSource = { kind: 'supabase'; table: string; url?: string; key?: string; realtime?: boolean }
76
- /** Embedded Postgres (PGlite) - a real, persistent database with ZERO backend
77
- * setup. Runs in the browser, persisting to IndexedDB. Swap for a `sql` source
78
- * pointed at hosted Postgres to go to production (same schema + SQL). */
79
- export type PgliteSource = { kind: 'pglite'; table: string; seed?: Record<string, unknown>[] }
80
- /** Where one entity's rows come from. */
81
- export type EntityDataSource = MemorySource | RestSource | SqlSource | SupabaseSource | PgliteSource
82
-
83
- /** The kinds of block a screen can hold: data-bound (entity-derived) or the
84
- * entity-agnostic `'component'` (a UI-kit component from `UI_COMPONENT_REGISTRY`,
85
- * usable on any screen, including a freestanding one with no entity). */
86
- export type BlockKind = 'grid' | 'form' | 'chart' | 'dashboard' | 'kpi' | 'gauge' | 'tree' | 'tabs' | 'accordion' | 'master-detail' | 'lookup' | 'pivot' | 'filter' | 'record' | 'board' | 'calendar' | 'detail' | 'component'
87
-
88
- export type GridAlign = 'left' | 'center' | 'right'
89
- /** No-code per-column value formatting. Compiles to the grid's `format` (CellFormatConfig):
90
- * a raw `1234.5` becomes `$1,234.50` / `42%` / `Jun 27, 2026` without a code renderer. */
91
- export type ColumnFormat =
92
- | { type: 'number'; decimals?: number }
93
- | { type: 'currency'; currency?: string }
94
- | { type: 'percent'; decimals?: number; /** cell values are 0-100 (42 -> 42%) not 0-1. */ valueIsPercentPoints?: boolean }
95
- | { type: 'date'; pattern?: string }
96
- | { type: 'datetime'; pattern?: string }
97
- /** No-code rich cell renderer for a column. Unlike `ColumnFormat` (value formatting),
98
- * these render a component/link: an auto-colored status pill, a progress bar, or a
99
- * clickable link. Compiles to a `cell` snippet on the column. Mutually exclusive with
100
- * `format` (a cell is either formatted text or a rich renderer). */
101
- export type ColumnCellType =
102
- | { kind: 'badge' }
103
- | { kind: 'progress'; max?: number }
104
- | { kind: 'link'; as?: 'url' | 'email' | 'tel' }
105
- export type GridColumnConfig = { field: string; show: boolean; header?: string; width?: number; align?: GridAlign; pin?: 'left' | 'right'; /** Aggregate this column into the group summary row when the grid is grouped. */ aggregate?: Reduce; /** No-code display formatting (currency / percent / date / ...). */ format?: ColumnFormat; /** No-code rich cell renderer (badge / progress / link). */ cellType?: ColumnCellType }
106
- /** How a grid edits its rows: read-only, inline (Excel-style cells), or a popup form. */
107
- export type GridEditing = 'none' | 'inline' | 'form'
108
- /** Row height preset. */
109
- export type GridDensity = 'compact' | 'normal' | 'comfortable'
110
- /** Where the pagination footer sits. */
111
- export type PagerPosition = 'top' | 'bottom' | 'both'
112
- export type GridConfig = {
113
- kind: 'grid'
114
- columns: GridColumnConfig[]
115
- pageSize: number
116
- selectable: boolean
117
- sortable: boolean
118
- filterable: boolean
119
- /** Which filtering surfaces show when `filterable` is on. Undefined = the global search
120
- * box only (back-compat). Combine a global search, a per-column filter row, and/or the
121
- * column header filter menu. */
122
- filterUi?: GridFilterUi
123
- editing: GridEditing
124
- /** Presentation of the edit form when `editing === 'form'`. */
125
- formPresentation: Presentation
126
- /** Form layout depth (all optional; default = the auto 2-column form of all fields). */
127
- formColumns?: 1 | 2 | 3
128
- /** Restrict + order the form's fields (field names). */
129
- formFields?: string[]
130
- /** Group fields into titled fieldsets. Overrides the entity's own
131
- * `EntitySchema.form.sections` for this grid's form. */
132
- formSections?: FormSection[]
133
- /** Dialog title override + width for the modal/drawer form. */
134
- formTitle?: string
135
- formSize?: 'sm' | 'md' | 'lg'
136
- density: GridDensity
137
- striped: boolean
138
- /** Excel-style cell/range selection. */
139
- cellSelection: boolean
140
- /** Show a totals/summary footer row. */
141
- rowSummaries: boolean
142
- /** Paginate (false shows all rows, no pager). */
143
- paginated: boolean
144
- /** Where the pager sits when paginated. */
145
- paginationPosition: PagerPosition
146
- /** Page-size choices in the pager's selector. */
147
- pageSizeOptions: number[]
148
- /** Drill-through: clicking a row navigates to `screen`, filtered by the clicked
149
- * row's `sourceField` value on the target's `targetField`. */
150
- rowLink?: RowLink
151
- /** Per-row action buttons (edit / delete / navigate) shown in an actions column. */
152
- rowActions?: RowAction[]
153
- /** No-code conditional formatting: color / bold a cell by its value. Compiled to
154
- * the grid's `conditionalFormats` rule engine. */
155
- formatRules?: FormatRule[]
156
- /** No-code row grouping: fields to group rows by (outermost first). When set, the
157
- * grid loads the full dataset and groups/sorts/paginates client-side (so groups
158
- * span every row, not just a server page) and shows the grouping controls. Per-column
159
- * `aggregate` values roll up into each group's summary row. */
160
- grouping?: string[]
161
- /** No-code export toolbar: a button bar above the grid wired to the grid's own
162
- * export API (CSV / JSON / copy-to-clipboard - no extra dependencies). */
163
- export?: GridExportConfig
164
- /** No-code tree data: render the grid's own rows as an expand/collapse hierarchy
165
- * built from a self-referential parent field. Mutually exclusive with `grouping`. */
166
- treeData?: TreeDataConfig
167
- /** Render the grid's rows as a calendar / scheduler (a view of the grid, like the
168
- * Kanban board). Mutually exclusive with grouping / tree. Uses the enterprise
169
- * scheduler renderer (`enableSchedulerView`). */
170
- scheduler?: SchedulerViewConfig
171
- /** Raw SvGrid prop overrides from the block's "All properties" panel - every grid
172
- * prop the curated controls don't manage. Passed straight through to `<SvGrid>`
173
- * (deduped against the curated props at codegen time). */
174
- props?: Record<string, unknown>
175
- }
176
- /** Which calendar view the scheduler opens on / offers. */
177
- export type SchedulerViewMode = 'month' | 'week' | 'day' | 'agenda' | 'timelineDay' | 'timelineWeek' | 'timelineMonth' | 'timelineYear'
178
- /** No-code scheduler-view config: map the entity's fields onto calendar events. Only
179
- * `startField` is required; the rest are optional refinements. Compiles to SvGrid's
180
- * `scheduler` prop + write-back handlers. */
181
- export type SchedulerViewConfig = {
182
- /** Field holding the event start (Date / epoch / ISO string). Required. */
183
- startField: string
184
- /** Field holding the event end. Omit to use a default duration from the start. */
185
- endField?: string
186
- /** Field for the event title. Defaults to the first column. */
187
- titleField?: string
188
- /** Field holding a per-event accent color. */
189
- colorField?: string
190
- /** Field that groups events into per-resource columns (people / rooms / machines). */
191
- resourceField?: string
192
- /** Field holding a recurrence rule (one event per matching day). */
193
- recurrenceField?: string
194
- /** Boolean field marking an all-day event. */
195
- allDayField?: string
196
- /** The view shown first. Default 'month'. */
197
- initialView?: SchedulerViewMode
198
- /** Enable drag-to-move + edge-resize (writes back through the data source). */
199
- editable?: boolean
200
- /** Show the built-in event detail drawer. */
201
- drawer?: boolean
202
- }
203
- /** Tree-data grid config: `parentField` is a self-referential FK (a row whose parent is
204
- * empty / not in the set is a root); `labelField` is the column that shows the indented,
205
- * expandable tree cell. */
206
- export type TreeDataConfig = { parentField: string; labelField: string }
207
- /** Which export affordances the grid toolbar shows. All go through the grid's built-in
208
- * export API (dependency-free). Empty / all-false = no toolbar. */
209
- /** Export buttons on a grid's toolbar.
210
- *
211
- * `csv` / `json` / `copy` use the free grid API. `xlsx` / `pdf` / `print` go
212
- * through `@svgrid/enterprise` (real OOXML, pdfmake, paginated print) and pull
213
- * in their optional peer deps - jszip for xlsx, pdfmake for pdf - which the
214
- * generated package.json declares only when the button is switched on. */
215
- export type GridExportConfig = {
216
- csv?: boolean
217
- json?: boolean
218
- copy?: boolean
219
- xlsx?: boolean
220
- pdf?: boolean
221
- print?: boolean
222
- }
223
- /** Which filtering surfaces the grid shows (when `filterable`). `global` = the search-all
224
- * box, `row` = a filter input under each header, `menu` = the column header filter menu. */
225
- export type GridFilterUi = { global?: boolean; row?: boolean; menu?: boolean }
226
- /** A conditional-formatting comparison. */
227
- export type FormatOp = 'eq' | 'ne' | 'lt' | 'lte' | 'gt' | 'gte' | 'contains' | 'empty' | 'notEmpty'
228
- /** One no-code format rule: style a `field`'s cell when the comparison holds. */
229
- export type FormatRule = { field: string; op: FormatOp; value?: string | number; color?: string; background?: string; bold?: boolean }
230
- /** A row-click drill-through to another screen. */
231
- export type RowLink = { screen: string; sourceField?: string; targetField: string }
232
- /** One per-row action button. */
233
- export type RowActionKind = 'edit' | 'delete' | 'navigate' | 'custom'
234
- export type RowAction = {
235
- kind: RowActionKind
236
- label?: string
237
- /** navigate: target screen id. */
238
- screen?: string
239
- /** navigate: field on this row whose value is passed (defaults to the id). */
240
- sourceField?: string
241
- /** navigate: field on the target entity to filter by. */
242
- targetField?: string
243
- /** custom: stable id - becomes the generated `/api/actions/<id>` route + handler
244
- * name. Required when `kind` is `'custom'`. */
245
- id?: string
246
- /** custom: icon glyph (rendered next to the label). */
247
- icon?: string
248
- /** custom: confirm before running, e.g. "Approve this order?". */
249
- confirm?: string
250
- }
251
-
252
- /** A custom action: a button wired to a generated stub API route + client
253
- * handler - the plumbing (fetch, loading state, error handling, RBAC gate) is
254
- * generated; the developer fills in the actual logic in the stub route.
255
- * Screen-level actions render in the screen's toolbar and work on ANY screen,
256
- * including one with no bound entity. Row-level actions (via `RowAction`'s
257
- * `'custom'` kind) render per-row in a grid, alongside edit/delete/navigate. */
258
- export type ActionConfig = { id: string; label: string; icon?: string; confirm?: string }
259
- /** Legacy standalone edit-form block. Editing is now a Grid property; kept so old
260
- * `studio.config.json` files still parse. Not offered in the palette. */
261
- export type FormConfig = { kind: 'form'; presentation: Presentation }
262
- /** A chart, optionally drilling into `drillScreen` (filtered by the clicked category). */
263
- export type ChartConfig = { kind: 'chart'; dimension: string; measure?: string; reduce: Reduce; type: ChartType; drillScreen?: string; dataLabels?: boolean; color?: string }
264
- /** Number format for a KPI value. `auto` keeps the legacy behavior ($ when the
265
- * measure's label carries `$`, else grouped number). */
266
- export type KpiFormat = 'auto' | 'number' | 'currency' | 'percent' | 'compact'
267
- export type KpiConfig = {
268
- kind: 'kpi'
269
- label: string
270
- measure?: string
271
- reduce: Reduce
272
- /** Value formatting. Defaults to `auto`. */
273
- format?: KpiFormat
274
- /** Field to bucket the measure by for an inline sparkline (e.g. a date or stage). */
275
- trendField?: string
276
- /** Aggregate per sparkline bucket. Defaults to `reduce`. */
277
- trendReduce?: Reduce
278
- /** Target value: shows a "% of target" delta chip. */
279
- target?: number
280
- }
281
- /** A radial gauge (SvGauge) of one aggregated measure within [min, max]. Like a
282
- * KPI but rendered as an arc - good for utilization, progress, scores. */
283
- export type GaugeConfig = { kind: 'gauge'; label: string; measure?: string; reduce: Reduce; min: number; max: number; unit?: string }
284
- /** A hierarchical tree (SvTree) built from the entity's own rows: `labelField` is
285
- * the node text, `parentField` is a self-referential FK (a row whose parent is
286
- * empty / unknown is a root). Good for categories, folders, org charts. */
287
- export type TreeConfig = { kind: 'tree'; labelField: string; parentField: string }
288
- /** One tab of a Tabs container: a label + its own ordered child blocks. */
289
- export type StudioTab = { label: string; blocks: Block[] }
290
- /** A tabbed container (SvTabs) grouping display blocks into tabs. Children are the
291
- * controller-free, `allRows`-driven blocks (see `TAB_CHILD_KINDS`). */
292
- export type TabsConfig = { kind: 'tabs'; tabs: StudioTab[] }
293
- /** One section of an Accordion container: a label + its own ordered child blocks. */
294
- export type AccordionSection = { label: string; blocks: Block[] }
295
- /** An accordion container (SvAccordion): collapsible sections, each hosting its own
296
- * child blocks (same container-child set as Tabs). `multiple` lets several sections
297
- * stay open at once (default: single-open). */
298
- export type AccordionConfig = { kind: 'accordion'; sections: AccordionSection[]; multiple?: boolean }
299
- export type DashboardConfig = { kind: 'dashboard' }
300
- export type MasterDetailConfig = { kind: 'master-detail'; childEntity: string; foreignKey: string; linkScreen?: string }
301
- export type LookupConfig = { kind: 'lookup'; field: string }
302
- /** A pivot table (SvPivotDesigner): row/column dimensions + one aggregated measure.
303
- * `aggregate` reuses the Reduce set (all valid pivot aggregators). */
304
- export type PivotConfig = { kind: 'pivot'; rows: string[]; cols: string[]; measure?: string; aggregate: Reduce }
305
- /** A faceted filter panel that drives the screen's grid. `fields` are the columns
306
- * exposed as facets (enum/boolean -> select, text -> contains search). */
307
- export type FilterPanelConfig = { kind: 'filter'; fields: string[]; title?: string }
308
- /** A record detail panel: shows the row selected in the screen's grid via
309
- * SvGridEditPanel. `fields` optionally narrows which fields show (empty = all). */
310
- export type RecordConfig = { kind: 'record'; fields?: string[]; editable: boolean; presentation?: Presentation }
311
- /** A Kanban board (SvBoard): columns are an `enum` field's options (`groupBy`),
312
- * rows become draggable cards titled by `titleField`. Dragging a card changes its
313
- * `groupBy` value. Optional `badgeField` (a chip) + `subtitleField` (secondary line). */
314
- export type BoardConfig = { kind: 'board'; groupBy: string; titleField: string; badgeField?: string; subtitleField?: string; openScreen?: string }
315
- /** A month event-calendar (SvSchedule): each row with a `dateField` value is an
316
- * event on that day, labelled by `titleField` and (optionally) tinted by an enum
317
- * `colorField`. Good for appointments / events / bookings / shifts. */
318
- export type CalendarConfig = { kind: 'calendar'; dateField: string; titleField: string; colorField?: string; openScreen?: string }
319
- /** A related child collection shown as a tab on a record detail page. */
320
- export type DetailRelated = { entity: string; foreignKey: string; label?: string; titleField?: string; subtitleField?: string; dateField?: string; statusField?: string; parentField?: string }
321
- /** A full record "detail page" (SvRecordDetail): a header (title + subtitle +
322
- * colored `statusField` pill + `metricFields` tiles), a tabbed Overview of
323
- * `sections` (field groups), and one tab per `related` child collection (a
324
- * timeline of the children pointing back at the record). The universal signature
325
- * view for relation-heavy entities where a board / calendar does not fit. */
326
- export type DetailConfig = { kind: 'detail'; titleField: string; subtitleField?: string; statusField?: string; metricFields?: string[]; sections?: { label: string; fields: string[] }[]; related?: DetailRelated[] }
327
- /** A UI-kit component (from `UI_COMPONENT_REGISTRY`, keyed by `component`) dropped
328
- * onto a screen. Entity-agnostic - works on a freestanding page or mixed onto an
329
- * entity-bound screen alike. `props` holds its configured "chrome" values, keyed
330
- * by the registry entry's prop `key`; a component with `hasContent` also stores
331
- * its literal text content under the reserved `_content` key. */
332
- /** A data binding for one component prop (or `_content`): its value is computed
333
- * from the screen's rows instead of a static literal. `aggregate` reduces a field
334
- * over all rows (a KPI-style number), `field` reads the first row's field, `expr`
335
- * is a raw JS expression over `rows`. Needs an entity screen (rows to bind to). */
336
- export type ComponentBinding =
337
- | { kind: 'aggregate'; field?: string; reduce: Reduce }
338
- | { kind: 'field'; field: string }
339
- | { kind: 'expr'; code: string }
340
- export type ComponentConfig = { kind: 'component'; component: string; props: Record<string, unknown>; name?: string; bindings?: Record<string, ComponentBinding> }
341
- export type BlockConfig =
342
- | GridConfig | FormConfig | ChartConfig | KpiConfig | GaugeConfig | TreeConfig | TabsConfig | AccordionConfig | DashboardConfig | MasterDetailConfig | LookupConfig
343
- | PivotConfig | FilterPanelConfig | RecordConfig | BoardConfig | CalendarConfig | DetailConfig | ComponentConfig
344
-
345
- /** Block kinds allowed inside a Tabs / Accordion container: the controller-free,
346
- * `allRows`-driven display blocks (no grid / form / master-detail, which need the
347
- * screen controller). `component` covers any UI-kit component added by key. */
348
- export const TAB_CHILD_KINDS: ReadonlyArray<BlockKind> = ['chart', 'kpi', 'gauge', 'dashboard', 'pivot', 'tree', 'component']
349
- /** Alias: the same set applies to Accordion sections. */
350
- export const CONTAINER_CHILD_KINDS = TAB_CHILD_KINDS
351
- /** Display-block child kinds (no `component`) - what the container "add block" menu offers;
352
- * components are added separately, by registry key, via `addContainerComponent`. */
353
- export const CONTAINER_DISPLAY_KINDS: ReadonlyArray<BlockKind> = ['chart', 'kpi', 'gauge', 'dashboard', 'pivot', 'tree']
354
-
355
- /** All blocks on a screen, flattened to include the children nested in Tabs
356
- * containers - used to detect kinds for imports / data loading. */
357
- export function flattenBlocks(blocks: ReadonlyArray<Block>): Block[] {
358
- const out: Block[] = []
359
- for (const b of blocks) {
360
- out.push(b)
361
- if (b.config.kind === 'tabs') for (const t of b.config.tabs) out.push(...flattenBlocks(t.blocks))
362
- if (b.config.kind === 'accordion') for (const s of b.config.sections) out.push(...flattenBlocks(s.blocks))
363
- }
364
- return out
365
- }
366
-
367
- /** User style overrides for a block's wrapper element (the card around the block).
368
- * Each field is an OVERRIDE - `undefined` keeps the block kind's default look. Applied
369
- * identically in the designer preview and the generated app (see `blockStyleCss`). */
370
- export type BlockStyle = {
371
- /** Force a border on (true) or off (false); undefined = the kind's default. */
372
- border?: boolean
373
- /** Force a drop shadow on (true) or off (false); undefined = default. */
374
- shadow?: boolean
375
- /** Inner padding, px. */
376
- padding?: number
377
- /** Outer margin, px. */
378
- margin?: number
379
- /** Background color (any CSS color). */
380
- background?: string
381
- /** Corner radius, px. */
382
- radius?: number
383
- }
384
-
385
- export type Block = {
386
- id: string
387
- /** Coarse width in a 3-col grid (legacy + quick buttons). `colSpan` overrides it. */
388
- span: 1 | 2 | 3
389
- /** Fine width in a 12-col grid (1-12), set by the horizontal drag / slider.
390
- * Falls back to `span * 4` (so 1/2/3 -> 4/8/12) when unset. */
391
- colSpan?: number
392
- /** Canvas/preview region height in px. Undefined = the kind's natural default.
393
- * Applies to height-driven blocks (grid, chart, master-detail). */
394
- height?: number
395
- /** Per-block appearance overrides (border / shadow / padding / margin / bg / radius). */
396
- style?: BlockStyle
397
- /** Extra CSS class(es) put on the block's wrapper, so custom.css can target it. */
398
- className?: string
399
- config: BlockConfig
400
- }
401
-
402
- /** Sanitize a user-typed class list to a safe value (letters/digits/_/- and spaces) so
403
- * it can never break out of a `class="..."` attribute. Shared by block/screen/app. */
404
- export function sanitizeClassName(raw: string | undefined): string {
405
- return (raw ?? '').replace(/[^a-zA-Z0-9 _-]/g, '').trim().replace(/\s+/g, ' ').slice(0, 120)
406
- }
407
- /** The sanitized extra class(es) for a block's wrapper. */
408
- export function blockClassName(block: Pick<Block, 'className'>): string {
409
- return sanitizeClassName(block.className)
410
- }
411
- /** The number of columns (1-12) a block occupies in the 12-col layout. */
412
- export const blockColumns = (b: Pick<Block, 'span' | 'colSpan'>): number =>
413
- // Default to full width when a block sets neither colSpan nor span (Copilot /
414
- // hand-authored configs may omit both) - never emit `span NaN` into the HTML.
415
- Math.max(1, Math.min(12, Math.round(b.colSpan ?? (b.span != null ? b.span * 4 : 12))))
416
-
417
- /** Restrict a user-typed color to a safe subset so it can't break out of an inline
418
- * `style="..."` attribute (hex, rgb()/hsl(), named colors, css vars). */
419
- const safeColor = (c: string): string => c.replace(/[^#a-zA-Z0-9(),.%\s_-]/g, '').slice(0, 64)
420
-
421
- /** The CSS declarations for a block's style overrides, for its wrapper element - shared
422
- * by codegen and the designer so the preview matches the generated app. Empty when no
423
- * overrides. Numeric fields are safe; the background color is sanitized. */
424
- export function blockStyleCss(style: BlockStyle | undefined): string {
425
- if (!style) return ''
426
- const d: string[] = []
427
- if (style.border === true) d.push('border: 1px solid var(--sg-border, #e6e8ec)')
428
- else if (style.border === false) d.push('border: none')
429
- if (style.shadow === true) d.push('box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06)')
430
- else if (style.shadow === false) d.push('box-shadow: none')
431
- if (typeof style.padding === 'number') d.push(`padding: ${style.padding}px`)
432
- if (typeof style.margin === 'number') d.push(`margin: ${style.margin}px`)
433
- if (style.background) d.push(`background: ${safeColor(style.background)}`)
434
- if (typeof style.radius === 'number') d.push(`border-radius: ${style.radius}px`)
435
- return d.join('; ')
436
- }
437
-
438
- /** Merge a partial style patch over a block's style, dropping keys set to undefined;
439
- * returns undefined when nothing is left (keeps the model + serialized config clean). */
440
- export function mergeBlockStyle(base: BlockStyle | undefined, patch: Partial<BlockStyle>): BlockStyle | undefined {
441
- const merged: Record<string, unknown> = { ...base, ...patch }
442
- for (const k of Object.keys(merged)) if (merged[k] === undefined) delete merged[k]
443
- return Object.keys(merged).length ? (merged as BlockStyle) : undefined
444
- }
445
- /** Navigation placement for a screen (Manage Pages: show/hide + label + order). */
446
- export type ScreenNav = { show?: boolean; label?: string; order?: number }
447
-
448
- /** The always-present page lifecycle handlers the Code view edits. Kept as named
449
- * constants so codegen, the designer, and the manifest agree on the slot names.
450
- * `onLoad` runs on mount (with the page context); `onDestroy` runs on unmount
451
- * (cleanup: timers, subscriptions, aborts). */
452
- export const ON_LOAD = 'onLoad'
453
- export const ON_DESTROY = 'onDestroy'
454
- /** Every lifecycle slot the Code view can edit, in display order. */
455
- export const HANDLER_SLOTS: ReadonlyArray<string> = [ON_LOAD, ON_DESTROY]
456
- /** The handler-steps key for a component block's click event. */
457
- export const clickSlot = (blockId: string) => `click:${blockId}`
458
- /** The handler-steps key for a grid block's row-select (row click) event. The
459
- * compiled steps get the clicked `row` in scope (use row-field values). */
460
- export const rowSelectSlot = (blockId: string) => `rowSelect:${blockId}`
461
- /** The handler-steps key for a component block's change (value change) event. */
462
- export const changeSlot = (blockId: string) => `change:${blockId}`
463
- /** Generic event slot - `click`/`change` produce the same keys as clickSlot/changeSlot,
464
- * so existing projects keep their wiring; any other declared component event
465
- * (focus, select, toggle, ...) gets its own `<event>:<blockId>` slot. */
466
- export const eventSlot = (event: string, blockId: string) => `${event}:${blockId}`
467
- /** The handler-steps key for the screen's form-submit (record saved) event. The
468
- * compiled steps get the submitted `row` (values) in scope. */
469
- export const FORM_SUBMIT = 'formSubmit'
470
-
471
- /** One event on the Grid, exposed to code-behind as `ctx.grid.<method> = (e) => {}`.
472
- * `prop` is the SvGrid callback it wires to (empty for `dataEvent`s, which fire from
473
- * Studio's data controller on create/update/delete rather than from the grid markup). */
474
- export type GridEventDef = { key: string; method: string; prop: string; params: string; builtin: boolean; desc: string; dataEvent?: boolean }
475
- /** The Grid's real event surface (mirrors SvGrid's `on*` callbacks). Studio wires
476
- * every one into `ctx.grid` so page code can subscribe with `ctx.grid.onCellClick =
477
- * (e) => {}`. `params` is the handler's parameter list, with `Row` as the row-type
478
- * placeholder (substituted per screen). `builtin` marks events a Studio feature may
479
- * already use (sort/filter/paginate/edit/row-click) - codegen COMPOSES the user's
480
- * handler onto the built-in rather than emitting a duplicate prop. Signatures are
481
- * transcribed from packages/grid/src/SvGrid.types.ts. */
482
- export const GRID_EVENTS: readonly GridEventDef[] = [
483
- { key: 'rowClick', method: 'onRowClick', prop: 'onRowClick', builtin: true, desc: 'A data row is single-clicked.', params: 'e: { rowIndex: number; columnId: string; row: Row }' },
484
- { key: 'rowDoubleClick', method: 'onRowDoubleClick', prop: 'onRowDoubleClick', builtin: true, desc: 'A data row is double-clicked.', params: 'e: { rowIndex: number; columnId: string; row: Row }' },
485
- { key: 'cellClick', method: 'onCellClick', prop: 'onCellClick', builtin: false, desc: 'A data cell is single-clicked.', params: 'e: { rowIndex: number; colIndex: number; columnId: string; value: unknown; row: Row }' },
486
- { key: 'cellDoubleClick', method: 'onCellDoubleClick', prop: 'onCellDoubleClick', builtin: false, desc: 'A data cell is double-clicked.', params: 'e: { rowIndex: number; colIndex: number; columnId: string; value: unknown; row: Row }' },
487
- { key: 'rowSelectionChange', method: 'onRowSelectionChange', prop: 'onRowSelectionChange', builtin: false, desc: 'The row selection changes.', params: 'selection: Record<string, boolean>, rows: Row[]' },
488
- { key: 'cellSelectionChange', method: 'onCellSelectionChange', prop: 'onCellSelectionChange', builtin: false, desc: 'The cell-selection rectangle changes.', params: 'ranges: Array<[number, number, number, number]>' },
489
- { key: 'activeCellChange', method: 'onActiveCellChange', prop: 'onActiveCellChange', builtin: false, desc: 'The active cell changes.', params: 'cell: { rowIndex: number; colIndex: number; columnId: string }' },
490
- { key: 'cellValueChange', method: 'onCellValueChange', prop: 'onCellValueChange', builtin: true, desc: 'An inline cell edit is committed.', params: 'e: { rowIndex: number; columnId: string; oldValue: unknown; newValue: unknown; row: Row }' },
491
- { key: 'sortingChange', method: 'onSortingChange', prop: 'onSortingChange', builtin: true, desc: 'The sort clauses change.', params: 'sorting: Array<{ id: string; desc: boolean }>' },
492
- { key: 'filtersChange', method: 'onFiltersChange', prop: 'onFiltersChange', builtin: true, desc: 'Any in-grid filter changes.', params: 'filters: { global: string; columns: Array<{ id: string; operator: string; value: string; valueTo?: string; selectedValues?: string[] }> }' },
493
- { key: 'paginationChange', method: 'onPaginationChange', prop: 'onPaginationChange', builtin: true, desc: 'The page or page size changes.', params: 'pagination: { pageIndex: number; pageSize: number }' },
494
- { key: 'columnOrderChange', method: 'onColumnOrderChange', prop: 'onColumnOrderChange', builtin: false, desc: 'The column order changes.', params: 'order: ReadonlyArray<string>' },
495
- { key: 'scrollBottomReached', method: 'onScrollBottomReached', prop: 'onScrollBottomReached', builtin: false, desc: 'The body scrolls near the bottom (lazy-load hook).', params: 'e: { scrollTop: number; scrollHeight: number; clientHeight: number }' },
496
- { key: 'noteChange', method: 'onNoteChange', prop: 'onNoteChange', builtin: false, desc: 'A cell note/comment is saved or removed (needs editable comments).', params: 'e: { rowId: string; columnId: string; note: string }' },
497
- { key: 'rowDragEnd', method: 'onRowDragEnd', prop: 'onRowDragEnd', builtin: false, desc: 'A managed row drag settles (needs row dragging enabled).', params: 'e: { row: Row; toIndex: number; sameGrid: boolean; fromGridId: number; toGridId: number }' },
498
- // Data-layer events - fired by the screen's data controller after a write settles
499
- // (form save, inline edit, delete action, or ctx.data.create/update/delete), not by
500
- // the grid markup. Only fire on entity screens (which have a data source).
501
- { key: 'rowAdded', method: 'onRowAdded', prop: '', builtin: false, dataEvent: true, desc: 'A row was created (form / ctx.data.create).', params: 'row: Row' },
502
- { key: 'rowUpdated', method: 'onRowUpdated', prop: '', builtin: false, dataEvent: true, desc: 'A row was updated (form / inline edit / ctx.data.update).', params: 'row: Row' },
503
- { key: 'rowDeleted', method: 'onRowDeleted', prop: '', builtin: false, dataEvent: true, desc: 'A row was deleted (delete action / ctx.data.delete).', params: 'id: string' },
504
- ]
505
-
506
- // --- logic core: screen state + a small expression engine --------------------
507
-
508
- /** A screen-scoped reactive variable (`ctx.state.<name>`). Emitted as `$state`. */
509
- export type StateVarType = 'string' | 'number' | 'boolean' | 'json'
510
- export type StateVar = { name: string; type: StateVarType; initial?: string }
511
-
512
- const identSafe = (s: string): string => (s || '').replace(/[^a-zA-Z0-9_$]/g, '_').replace(/^([0-9])/, '_$1') || 'v'
513
-
514
- /** The `$state(...)` initializer for a variable's declared type. */
515
- export function stateInitExpr(v: StateVar): string {
516
- const raw = (v.initial ?? '').trim()
517
- switch (v.type) {
518
- case 'number': return raw === '' || Number.isNaN(Number(raw)) ? '0' : raw
519
- case 'boolean': return raw === 'true' ? 'true' : 'false'
520
- case 'json': return raw || 'null'
521
- case 'string': return q(v.initial ?? '')
522
- }
523
- }
524
- /** The TS type annotation for a variable. */
525
- export function stateTsType(v: StateVar): string {
526
- return v.type === 'number' ? 'number' : v.type === 'boolean' ? 'boolean' : v.type === 'json' ? 'unknown' : 'string'
527
- }
528
-
529
- /** A value expression usable in steps / conditions: a literal, a state var, a URL
530
- * param, or the current row (for row-scoped handlers). Compiles to a `ctx` expr. */
531
- export type LogicValue =
532
- | { kind: 'literal'; value: string }
533
- | { kind: 'state'; name: string }
534
- | { kind: 'param'; name: string }
535
- | { kind: 'field'; name: string } // the current row's field (row-scoped slots)
536
- export function compileValue(v: LogicValue, rowExpr = 'row'): string {
537
- switch (v.kind) {
538
- case 'state': return `ctx.state.${identSafe(v.name)}`
539
- case 'param': return `ctx.params[${q(v.name)}]`
540
- case 'field': return `${rowExpr}?.[${q(v.name)}]`
541
- case 'literal': return litValue(v.value)
542
- }
543
- }
544
-
545
- export type LogicOp = 'eq' | 'neq' | 'gt' | 'lt' | 'gte' | 'lte' | 'contains' | 'truthy' | 'falsy'
546
- export type Condition = { left: LogicValue; op: LogicOp; right?: LogicValue }
547
- export function compileCondition(c: Condition, rowExpr = 'row'): string {
548
- const L = compileValue(c.left, rowExpr)
549
- const R = c.right ? compileValue(c.right, rowExpr) : "''"
550
- switch (c.op) {
551
- case 'eq': return `${L} === ${R}`
552
- case 'neq': return `${L} !== ${R}`
553
- case 'gt': return `Number(${L}) > Number(${R})`
554
- case 'lt': return `Number(${L}) < Number(${R})`
555
- case 'gte': return `Number(${L}) >= Number(${R})`
556
- case 'lte': return `Number(${L}) <= Number(${R})`
557
- case 'contains': return `String(${L}).includes(String(${R}))`
558
- case 'truthy': return `Boolean(${L})`
559
- case 'falsy': return `!(${L})`
560
- }
561
- }
562
-
563
- /** A field assignment used by create / update record steps. */
564
- export type FieldValue = { field: string; value: LogicValue }
565
- const compileFields = (fs: ReadonlyArray<FieldValue>): string => `{ ${fs.map((f) => `${identSafe(f.field)}: ${compileValue(f.value)}`).join(', ')} }`
566
-
567
- /** One step in a visual "method" (the Methods panel, Radzen-style). Each compiles
568
- * to a line of typed `ctx` code-behind - so the visual builder and the code editor
569
- * produce identical output. `code` is the raw-TS escape hatch. */
570
- export type ActionStep =
571
- | { type: 'navigate'; to: string } // ctx.goto(to)
572
- | { type: 'gridExport'; format?: 'csv' | 'tsv' | 'json' } // download the grid
573
- | { type: 'gridCopy' } // ctx.grid.copyToClipboard()
574
- | { type: 'gridClear'; what: 'filters' | 'sort' | 'selection' }
575
- | { type: 'gridSort'; field: string; dir?: 'asc' | 'desc' }
576
- | { type: 'gridFilter'; field: string; value: string }
577
- | { type: 'reloadData' } // ctx.data.reload()
578
- | { type: 'setProp'; target: string; prop: string; value: string } // ctx.<target>.<prop> = <value>
579
- | { type: 'setText'; target: string; value: string } // ctx.<target>.text = <value>
580
- | { type: 'alert'; message: string }
581
- | { type: 'apiAction'; actionId: string } // POST /api/actions/<id>
582
- | { type: 'setVar'; name: string; value: LogicValue } // ctx.state.<name> = <value>
583
- | { type: 'createRecord'; values: FieldValue[] } // await ctx.data.create({...})
584
- | { type: 'updateRecord'; id: LogicValue; values: FieldValue[] } // await ctx.data.update(id, {...})
585
- | { type: 'deleteRecord'; id: LogicValue } // await ctx.data.delete(id)
586
- | { type: 'branch'; condition: Condition; then: ActionStep[]; else?: ActionStep[] } // if / else
587
- | { type: 'code'; code: string }
588
- export type ActionStepType = ActionStep['type']
589
-
590
- const q = (s: string) => `'${String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
591
- /** Emit a value literal: true/false/number bare, else a quoted string. */
592
- const litValue = (v: string): string => {
593
- const t = v.trim()
594
- if (t === 'true' || t === 'false') return t
595
- if (t !== '' && !Number.isNaN(Number(t))) return t
596
- return q(v)
597
- }
598
-
599
- /** Compile one step to a `ctx` statement (a single line, no trailing newline). */
600
- export function compileStep(step: ActionStep): string {
601
- switch (step.type) {
602
- case 'navigate': return `ctx.goto(${q(step.to)})`
603
- case 'gridExport': {
604
- const fmt = step.format ?? 'csv'
605
- const fn = fmt === 'tsv' ? 'exportTsv' : fmt === 'json' ? 'exportJson' : 'exportCsv'
606
- const mime = fmt === 'json' ? 'application/json' : fmt === 'tsv' ? 'text/tab-separated-values' : 'text/csv'
607
- // A scoped block so the locals don't collide when there are two export steps.
608
- return [
609
- '{',
610
- ` const data = await ctx.grid.${fn}()`,
611
- " const link = document.createElement('a')",
612
- ` link.href = URL.createObjectURL(new Blob([data], { type: ${q(mime)} }))`,
613
- ` link.download = ${q('export.' + fmt)}`,
614
- ' link.click()',
615
- ' URL.revokeObjectURL(link.href)',
616
- '}',
617
- ].join('\n')
618
- }
619
- case 'gridCopy': return `ctx.grid.copyToClipboard()`
620
- case 'gridClear': return step.what === 'sort' ? `ctx.grid.clearSort()` : step.what === 'selection' ? `ctx.grid.clearRowSelection()` : `ctx.grid.clearAllFilters()`
621
- case 'gridSort': return `ctx.grid.setSort(${q(step.field)}, ${q(step.dir ?? 'asc')})`
622
- case 'gridFilter': return `ctx.grid.setFilter(${q(step.field)}, { operator: 'contains', value: ${q(step.value)} })`
623
- case 'reloadData': return `await ctx.data.reload()`
624
- case 'setProp': return `ctx.${step.target}.${step.prop} = ${litValue(step.value)}`
625
- case 'setText': return `ctx.${step.target}.text = ${litValue(step.value)}`
626
- case 'alert': return `alert(${q(step.message)})`
627
- case 'apiAction': return `await fetch(${q('/api/actions/' + step.actionId)}, { method: 'POST' })`
628
- case 'setVar': return `ctx.state.${identSafe(step.name)} = ${compileValue(step.value)}`
629
- case 'createRecord': return `await ctx.data.create(${compileFields(step.values)})`
630
- case 'updateRecord': return `await ctx.data.update(${compileValue(step.id)}, ${compileFields(step.values)})`
631
- case 'deleteRecord': return `await ctx.data.delete(${compileValue(step.id)})`
632
- case 'branch': {
633
- const body = indentLines(compileHandlerSteps(step.then))
634
- const elseB = step.else?.length ? ` else {\n${indentLines(compileHandlerSteps(step.else))}\n}` : ''
635
- return `if (${compileCondition(step.condition)}) {\n${body}\n}${elseB}`
636
- }
637
- case 'code': return step.code
638
- }
639
- }
640
- /** Indent every line by 2 spaces (nested branch bodies). */
641
- const indentLines = (s: string): string => s.split('\n').map((l) => (l ? ' ' + l : l)).join('\n')
642
- /** Compile a list of steps to a handler body (indented lines). */
643
- export function compileHandlerSteps(steps: ReadonlyArray<ActionStep>): string {
644
- return steps.map((s) => compileStep(s)).join('\n')
645
- }
646
-
647
- // --- entity triggers: server-side business rules -----------------------------
648
- // Compiled into the SQL route's createKitHandlers `hooks`, so they run + are
649
- // ENFORCED on the server (a client that skips its checks still can't write bad
650
- // data). The payload is a mutable record `v`; `field`-kind values read `v[...]`.
651
-
652
- /** One step in an entity trigger (a server-side rule). */
653
- export type TriggerStep =
654
- | { type: 'setField'; field: string; value: LogicValue } // v[field] = value (transform)
655
- | { type: 'requireField'; field: string; message?: string } // reject if empty
656
- | { type: 'reject'; condition: Condition; message: string } // reject when the condition holds
657
- | { type: 'branch'; condition: Condition; then: TriggerStep[]; else?: TriggerStep[] }
658
- | { type: 'code'; code: string }
659
- export type TriggerEvent = 'beforeCreate' | 'afterCreate' | 'beforeUpdate' | 'afterUpdate' | 'beforeDelete' | 'afterDelete'
660
- /** All trigger events, in display order; before-hooks transform/validate, after-hooks are side effects. */
661
- export const TRIGGER_EVENTS: ReadonlyArray<TriggerEvent> = ['beforeCreate', 'afterCreate', 'beforeUpdate', 'afterUpdate', 'beforeDelete', 'afterDelete']
662
- export type EntityTriggers = Partial<Record<TriggerEvent, TriggerStep[]>>
663
-
664
- /** Compile one trigger step against the payload variable `rowExpr` (default `v`). */
665
- export function compileTriggerStep(step: TriggerStep, rowExpr = 'v'): string {
666
- switch (step.type) {
667
- case 'setField': return `${rowExpr}[${q(step.field)}] = ${compileValue(step.value, rowExpr)}`
668
- case 'requireField': return `if (${rowExpr}[${q(step.field)}] == null || ${rowExpr}[${q(step.field)}] === '') throw new Error(${q(step.message || step.field + ' is required')})`
669
- case 'reject': return `if (${compileCondition(step.condition, rowExpr)}) throw new Error(${q(step.message)})`
670
- case 'branch': {
671
- const body = indentLines(compileTriggerSteps(step.then, rowExpr))
672
- const elseB = step.else?.length ? ` else {\n${indentLines(compileTriggerSteps(step.else, rowExpr))}\n}` : ''
673
- return `if (${compileCondition(step.condition, rowExpr)}) {\n${body}\n}${elseB}`
674
- }
675
- case 'code': return step.code
676
- }
677
- }
678
- export function compileTriggerSteps(steps: ReadonlyArray<TriggerStep>, rowExpr = 'v'): string {
679
- return steps.map((s) => compileTriggerStep(s, rowExpr)).join('\n')
680
- }
681
-
682
- /** `entity` is optional: a screen with none is a freestanding page (no data
683
- * binding, no blocks - every `BlockKind` is entity-bound) - just a title, an
684
- * empty content area, and optionally a toolbar of custom `actions`. Wire up a
685
- * "Run report" / "Sync now" button on a blank page without a table behind it.
686
- *
687
- * `code` opts the screen into a create-once `handlers.ts` companion (user-owned,
688
- * never regenerated) whose `onLoad(ctx)` runs on mount; `renderGrid` adds a Grid
689
- * fed via `ctx.setRows`. `handlerBodies` holds each handler's body (keyed by name). */
690
- /** How a screen arranges its blocks: the default 12-column responsive grid, or a
691
- * docking workspace (SvDockManager - drag-to-dock splits/tabs + floating / pinned panes,
692
- * serialized as `dock`). */
693
- export type ScreenLayout = 'grid' | 'stack' | 'split' | 'dock' | 'canvas'
694
-
695
- /** Layouts that arrange blocks as dockable / split panes (both back onto a
696
- * serialized `DockManagerState`; `split` renders it locked = resize-only). */
697
- export const PANE_LAYOUTS: ReadonlyArray<ScreenLayout> = ['split', 'dock']
698
- export function isPaneLayout(layout: ScreenLayout): boolean { return PANE_LAYOUTS.includes(layout) }
699
-
700
- /** Free-form canvas: a block is placed on a 12-column grid by explicit cell
701
- * coordinates (col/row) + spans, instead of flowing. `row`/`rowSpan` count
702
- * fixed-height rows (see CANVAS_ROW_PX). All values are grid cells, 0-indexed. */
703
- export type CanvasRect = { col: number; row: number; colSpan: number; rowSpan: number }
704
- export const CANVAS_COLS = 12
705
- /** Height of one canvas row in px (grid-auto-rows), shared by designer + codegen. */
706
- export const CANVAS_ROW_PX = 40
707
- /** Gap between canvas cells in px. */
708
- export const CANVAS_GAP_PX = 8
709
- // --- per-layout settings ----------------------------------------------------
710
- // Each mode has its own knobs; stored per-mode so switching layouts keeps each
711
- // mode's settings. All fields optional - absent means the default (below), so
712
- // existing projects are unchanged.
713
- export type GridLayoutOpts = { colGap?: number; rowGap?: number; maxWidth?: number; align?: 'start' | 'stretch'; mobileBreakpoint?: number }
714
- export type StackLayoutOpts = { gap?: number; maxWidth?: number; align?: 'left' | 'center'; dividers?: boolean; minHeight?: number }
715
- export type SplitLayoutOpts = { orientation?: 'auto' | 'row' | 'column'; minPaneSize?: number; persist?: boolean }
716
- export type DockLayoutOpts = { allowPopout?: boolean; hideSingleTab?: boolean; headerPosition?: 'top' | 'bottom' | 'left' | 'right'; persist?: boolean }
717
- export type CanvasLayoutOpts = { cols?: number; rowHeight?: number; gap?: number; showGrid?: boolean }
718
- export type LayoutOpts = { grid?: GridLayoutOpts; stack?: StackLayoutOpts; split?: SplitLayoutOpts; dock?: DockLayoutOpts; canvas?: CanvasLayoutOpts }
719
-
720
- /** `maxWidth: 0` (or absent) means full-bleed. */
721
- export const GRID_OPT_DEFAULTS: Required<GridLayoutOpts> = { colGap: 16, rowGap: 16, maxWidth: 0, align: 'start', mobileBreakpoint: 720 }
722
- export const STACK_OPT_DEFAULTS: Required<StackLayoutOpts> = { gap: 16, maxWidth: 0, align: 'left', dividers: false, minHeight: 160 }
723
- export const SPLIT_OPT_DEFAULTS: Required<SplitLayoutOpts> = { orientation: 'auto', minPaneSize: 80, persist: true }
724
- export const DOCK_OPT_DEFAULTS: Required<DockLayoutOpts> = { allowPopout: false, hideSingleTab: false, headerPosition: 'top', persist: true }
725
- export const CANVAS_OPT_DEFAULTS: Required<CanvasLayoutOpts> = { cols: CANVAS_COLS, rowHeight: CANVAS_ROW_PX, gap: CANVAS_GAP_PX, showGrid: false }
726
-
727
- export const gridOpts = (s: Screen): Required<GridLayoutOpts> => ({ ...GRID_OPT_DEFAULTS, ...s.layoutOpts?.grid })
728
- export const stackOpts = (s: Screen): Required<StackLayoutOpts> => ({ ...STACK_OPT_DEFAULTS, ...s.layoutOpts?.stack })
729
- export const splitOpts = (s: Screen): Required<SplitLayoutOpts> => ({ ...SPLIT_OPT_DEFAULTS, ...s.layoutOpts?.split })
730
- export const dockOpts = (s: Screen): Required<DockLayoutOpts> => ({ ...DOCK_OPT_DEFAULTS, ...s.layoutOpts?.dock })
731
- export const canvasOpts = (s: Screen): Required<CanvasLayoutOpts> => ({ ...CANVAS_OPT_DEFAULTS, ...s.layoutOpts?.canvas })
732
-
733
- /** Patch one mode's settings (merged over any existing values). */
734
- export function setLayoutOpts<K extends keyof LayoutOpts>(project: StudioProject, screenId: string, mode: K, patch: Partial<NonNullable<LayoutOpts[K]>>): StudioProject {
735
- return mapScreen(project, screenId, (s) => ({ ...s, layoutOpts: { ...s.layoutOpts, [mode]: { ...s.layoutOpts?.[mode], ...patch } } }))
736
- }
737
-
738
- export type Screen = { id: string; entity?: string; title: string; route: string; blocks: Block[]; nav?: ScreenNav; actions?: ActionConfig[]; code?: boolean; renderGrid?: boolean; handlerBodies?: Record<string, string>; handlerSteps?: Record<string, ActionStep[]>; handlersSource?: string; className?: string; layout?: ScreenLayout; dock?: DockManagerState; canvas?: Record<string, CanvasRect>; layoutOpts?: LayoutOpts; state?: StateVar[]; renderMode?: 'ssr' | 'spa' }
739
-
740
- /** The generated app's shell (master layout): sidebar, top-nav, or bottom-nav; brand, footer. */
741
- export type ShellStyle = 'sidebar' | 'top-nav' | 'bottom-nav'
742
- export type ShellConfig = { style?: ShellStyle; brand?: string; footer?: string; navPosition?: 'left' | 'right'; logo?: string; toolbar?: boolean }
743
- export type ProjectTheme = { accent?: string; preset?: string; mode?: 'light' | 'dark'; shell?: ShellConfig; customCss?: string; appClass?: string }
744
-
745
- /** A mutating CRUD action, gated by RBAC (reads are implied by screen access). */
746
- export type CrudAction = 'create' | 'update' | 'delete'
747
- export const CRUD_ACTIONS: readonly CrudAction[] = ['create', 'update', 'delete']
748
- /** Access rules for one role. */
749
- export type RoleAccess = {
750
- role: string
751
- /** Screen ids the role can open. `'*'` = every screen. */
752
- screens: '*' | string[]
753
- /** Write actions the role may perform. `'*'` = all; read is implied by screen access. */
754
- actions: '*' | CrudAction[]
755
- }
756
- /** Role-based access control for the app. When `enabled`, the generator emits a
757
- * `src/lib/access.ts` and gates nav / actions in the UI AND the server route. */
758
- export type AccessControl = {
759
- enabled: boolean
760
- roles: RoleAccess[]
761
- /** Fallback role when the app can't resolve one from the session. Default-denies. */
762
- defaultRole?: string
763
- }
764
- /** Authentication starter. When `enabled`, the generator scaffolds a real sign-in:
765
- * a session cookie + `hooks.server.ts` that populates `event.locals.role`/`user`
766
- * (closing the loop the RBAC layer expects), a `/login` page, sign-out, and demo
767
- * seed users (one per RBAC role, or a single admin). Dependency-free: Web Crypto +
768
- * stateless signed cookies. `protect` gates the whole app behind login (default). */
769
- /** Social / enterprise sign-in providers. `oidc` is a generic OpenID Connect issuer
770
- * (covers Azure AD / Entra ID, Okta, Auth0, Keycloak, ... via discovery). */
771
- export type OAuthProvider = 'github' | 'google' | 'oidc'
772
- export type AuthConfig = {
773
- enabled: boolean
774
- /** Which sign-in system to scaffold. `'builtin'` (default) is the dependency-free
775
- * cookie-session starter below (own user store, server route guards, RBAC).
776
- * `'supabase'` delegates to Supabase Auth: a client-side `SvAuthGate` over the
777
- * shared Supabase client, pairing with the database's Row Level Security. The
778
- * builtin-only sub-options (register / userAdmin / oauth / twoFactor / email) do
779
- * not apply to the Supabase provider. */
780
- provider?: 'builtin' | 'supabase'
781
- /** Redirect unauthenticated visitors to /login for every route. Default true. */
782
- protect?: boolean
783
- /** Self-service sign-up + password recovery (/register, /forgot-password,
784
- * /reset-password). Needs the DB-backed user store (turn on the data layer). */
785
- register?: boolean
786
- /** An admin user-management screen (/users: list, invite, change role, remove).
787
- * Needs the DB-backed store + RBAC (only a full-access role can open it). */
788
- userAdmin?: boolean
789
- /** OAuth / OpenID Connect sign-in buttons. Needs the DB-backed store. */
790
- oauth?: OAuthProvider[]
791
- /** Email one-time-code two-factor auth. Needs the DB-backed store + real email. */
792
- twoFactor?: boolean
793
- /** Real email delivery (Resend HTTP API or SMTP via nodemailer); dev console otherwise.
794
- * Implied when `twoFactor` is on. */
795
- email?: boolean
796
- }
797
- /** Does a role's rules permit opening a screen? */
798
- export const roleCanScreen = (r: RoleAccess, screenId: string): boolean =>
799
- r.screens === '*' || r.screens.includes(screenId)
800
- /** Does a role's rules permit a write action? */
801
- export const roleCanAction = (r: RoleAccess, action: CrudAction): boolean =>
802
- r.actions === '*' || r.actions.includes(action)
803
-
804
- export type StudioProject = {
805
- title: string
806
- entities: EntitySchema[]
807
- screens: Screen[]
808
- /** Default source kind for new entities; per-entity overrides live in `dataSources`. */
809
- dataSource: DataSourceKind
810
- /** Per-entity data-source binding, keyed by entity name. */
811
- dataSources?: Record<string, EntityDataSource>
812
- /** Project-level Supabase connection (URL + anon key), shared by every
813
- * Supabase-bound entity so it's set once. A per-entity `SupabaseSource.url`/`key`
814
- * still overrides for that entity. Read by the wizard + the `.env` paste hint. */
815
- supabase?: { url?: string; key?: string }
816
- theme?: ProjectTheme
817
- /** Role-based access control (optional; off unless `access.enabled`). */
818
- access?: AccessControl
819
- /** Authentication starter (optional; off unless `auth.enabled`). Provides the
820
- * session/login that populates the role RBAC consumes. */
821
- auth?: AuthConfig
822
- /** Typed data layer: when `'drizzle'` and there's a SQL-bound entity, emit a
823
- * Drizzle schema + typed repositories + drizzle-kit migrations. */
824
- dataLayer?: 'drizzle'
825
- /** Emit an audit trail: connected routes log create/update/delete + an /audit viewer. */
826
- audit?: boolean
827
- /** Localization: when enabled, emit a message catalog + locale switcher and route
828
- * nav / titles / column headers through `t()`. */
829
- i18n?: I18nConfig
830
- /** Deploy target: picks the SvelteKit adapter + provider config the bundle emits.
831
- * Defaults to `auto` (@sveltejs/adapter-auto, which detects Vercel/Netlify/Cloudflare). */
832
- deploy?: DeployTarget
833
- /** Server-side business-rule triggers, keyed by entity name. Enforced on the
834
- * SQL route (compiled into createKitHandlers `hooks`). */
835
- triggers?: Record<string, EntityTriggers>
836
- /** Scheduled background jobs: a guarded `/api/cron` endpoint plus the schedule
837
- * config for the deploy target. Empty / omitted emits nothing. */
838
- jobs?: ScheduledJob[]
839
- /** Multi-tenancy: scope every row to the signed-in user's tenant. */
840
- tenancy?: TenancyConfig
841
- }
842
-
843
- /**
844
- * Multi-tenancy for the generated app: one database, one deployment, rows
845
- * partitioned by a tenant column.
846
- *
847
- * Enforced on the SERVER, in the API route's `scope` option - reads are
848
- * filtered, creates are stamped, and updates/deletes re-read the target under
849
- * the scope first. A client that skips the UI still cannot reach another
850
- * tenant's rows.
851
- *
852
- * Requires the [auth starter](./auth.md) (the tenant comes from the session)
853
- * and the typed data layer (the column has to exist in the schema). Without
854
- * both it degrades to off rather than emitting a half-enforced scope.
855
- */
856
- export type TenancyConfig = {
857
- enabled: boolean
858
- /** Column holding the tenant key on every scoped entity. Default `tenantId`. */
859
- field?: string
860
- /** Entities to leave GLOBAL (shared across tenants) - reference/lookup tables
861
- * like currencies or countries that every tenant reads. */
862
- sharedEntities?: string[]
863
- }
864
-
865
- /** What a scheduled job does when it fires.
866
- * - `email`: send a summary of an entity (row count + the newest rows).
867
- * - `code`: run a body you write, with `db` / `sendEmail` in scope. */
868
- export type ScheduledJobKind = 'email' | 'code'
869
-
870
- /**
871
- * One scheduled background job.
872
- *
873
- * Jobs run on the SERVER via a generated `/api/cron` route, not in a browser
874
- * tab - unlike the client-side `createScheduler` in `@svgrid/enterprise`, which
875
- * only fires while the app is open. The route is secret-guarded and invoked by
876
- * the platform's scheduler (Vercel Cron, a GitHub Actions schedule, or your own
877
- * crontab hitting the URL).
878
- */
879
- export type ScheduledJob = {
880
- /** Stable id - the `?job=` selector and the handler name. */
881
- id: string
882
- /** Human label used in comments + the DEPLOY.md table. */
883
- name: string
884
- /** Standard 5-field cron expression, interpreted in UTC. */
885
- cron: string
886
- /** Off by default is surprising for something you explicitly added, so a job
887
- * runs unless `enabled` is explicitly false. */
888
- enabled?: boolean
889
- kind: ScheduledJobKind
890
- /** `email`: the entity to summarize. */
891
- entity?: string
892
- /** `email`: recipient address. */
893
- to?: string
894
- /** `email`: subject line. Defaults to the job name. */
895
- subject?: string
896
- /** `code`: the handler body (TypeScript). */
897
- code?: string
898
- }
899
-
900
- /** The render mode for a screen. `'ssr'` emits idiomatic SvelteKit (`+page.server.ts`
901
- * with a `load` + form `actions`, SSR + progressive enhancement); `'spa'` (the
902
- * default) emits the client data-source-controller page. Opt-in for now. */
903
- export function screenRenderMode(_project: StudioProject, screen: Screen): 'ssr' | 'spa' {
904
- return screen.renderMode === 'ssr' ? 'ssr' : 'spa'
905
- }
906
-
907
- /** Block kinds a read-only SSR screen can host: pure renders over server-loaded
908
- * rows. Board/calendar/scheduler (client interaction runtimes), components
909
- * (client bindings), grids-with-extras, and containers stay SPA. */
910
- const SSR_READ_KINDS = new Set<BlockKind>(['chart', 'pivot', 'dashboard', 'kpi', 'gauge', 'tree', 'detail', 'master-detail'])
911
-
912
- /** How a screen would emit under SSR: 'grid' (single grid -> load + form actions,
913
- * URL-driven sort/filter/page), 'read' (data-viz/detail blocks -> load only), or
914
- * null when it must stay SPA. */
915
- export function ssrScreenShape(project: StudioProject, screen: Screen): 'grid' | 'read' | null {
916
- if (!screen.entity || screen.code) return null
917
- const source = project.dataSources?.[screen.entity]
918
- const kind = source?.kind ?? project.dataSource
919
- // memory runs the source in-process; sql reuses the connected /api route via
920
- // event.fetch; rest calls the remote API straight from the server, which needs
921
- // an absolute URL to resolve there. (supabase and pglite stay SPA: pglite only
922
- // exists in the browser, and a Supabase read carries the signed-in user's token,
923
- // which a server-side call with the anon key would silently drop.)
924
- if (kind === 'rest') {
925
- if (!(source?.kind === 'rest' && /^https?:\/\//i.test(source.baseUrl ?? ''))) return null
926
- } else if (kind !== 'memory' && kind !== 'sql') {
927
- return null
928
- }
929
- const blocks = screen.blocks ?? []
930
- // A grid, optionally preceded by a facet panel. The panel is the reason this
931
- // allowance exists: every list screen the generator produces is [filter, grid],
932
- // so without it the shipped app shape could never render on the server. Facets
933
- // map cleanly onto the URL params `planFromSearchParams` already reads.
934
- const grids = blocks.filter((b) => b.config.kind === 'grid')
935
- if (grids.length === 1 && blocks.every((b) => b.config.kind === 'grid' || b.config.kind === 'filter')) {
936
- const g = grids[0]!.config as GridConfig
937
- if (g.treeData || g.scheduler) return null
938
- return 'grid'
939
- }
940
- if (blocks.length >= 1 && blocks.every((b) => SSR_READ_KINDS.has(b.config.kind))) return 'read'
941
- return null
942
- }
943
-
944
- /** Whether a screen can be emitted as SSR-native (any supported shape). */
945
- export function ssrEligible(project: StudioProject, screen: Screen): boolean {
946
- return ssrScreenShape(project, screen) !== null
947
- }
948
-
949
- /** Set a screen's render mode ('spa' clears back to the default). */
950
- export function setScreenRenderMode(project: StudioProject, screenId: string, mode: 'ssr' | 'spa'): StudioProject {
951
- return {
952
- ...project,
953
- screens: project.screens.map((s) => (s.id === screenId ? { ...s, renderMode: mode === 'ssr' ? 'ssr' : undefined } : s)),
954
- }
955
- }
956
-
957
- /** True when the screen should actually emit as SSR (mode is 'ssr' AND eligible). */
958
- export function isSsrScreen(project: StudioProject, screen: Screen): boolean {
959
- return screenRenderMode(project, screen) === 'ssr' && ssrEligible(project, screen)
960
- }
961
-
962
- /**
963
- * Turn server rendering on for every screen that can support it, for entities
964
- * backed by a real database. Applied when a project is first generated, so a new
965
- * app reads as idiomatic SvelteKit (a `load` and form `actions`) rather than a
966
- * client SPA. Screens that already state a mode are left alone, so this never
967
- * overrides a choice somebody made.
968
- *
969
- * Deliberately limited to sources that hold one copy of the data. In-memory and
970
- * PGlite sources are module singletons, so an SSR screen would read and write the
971
- * *server's* copy of the rows while the app's remaining SPA screens read the
972
- * browser's - create a row on one and the other never sees it. SQL and REST have
973
- * no such split: every path goes to the same database or the same remote API.
974
- */
975
- const SSR_DEFAULT_KINDS = new Set<DataSourceKind>(['sql', 'rest'])
976
-
977
- export function withSsrDefaults(project: StudioProject): StudioProject {
978
- return {
979
- ...project,
980
- screens: project.screens.map((screen) => {
981
- if (screen.renderMode || !screen.entity) return screen
982
- const kind = project.dataSources?.[screen.entity]?.kind ?? project.dataSource
983
- if (!SSR_DEFAULT_KINDS.has(kind)) return screen
984
- return ssrEligible(project, screen) ? { ...screen, renderMode: 'ssr' } : screen
985
- }),
986
- }
987
- }
988
-
989
- /** The triggers configured for an entity (or an empty object). */
990
- export function triggersOf(project: StudioProject, entity: string): EntityTriggers {
991
- return project.triggers?.[entity] ?? {}
992
- }
993
- /** Set (or clear, with `null`) an entity's steps for one trigger event. */
994
- export function setTrigger(project: StudioProject, entity: string, event: TriggerEvent, steps: TriggerStep[] | null): StudioProject {
995
- const cur = project.triggers?.[entity] ?? {}
996
- const nextEnt: EntityTriggers = { ...cur }
997
- if (steps && steps.length) nextEnt[event] = steps
998
- else delete nextEnt[event]
999
- const triggers = { ...project.triggers }
1000
- if (Object.keys(nextEnt).length) triggers[entity] = nextEnt
1001
- else delete triggers[entity]
1002
- return { ...project, triggers: Object.keys(triggers).length ? triggers : undefined }
1003
- }
1004
-
1005
- /** Turn multi-tenancy on/off and configure the scoping column. */
1006
- export function setTenancy(project: StudioProject, tenancy: TenancyConfig | null): StudioProject {
1007
- if (!tenancy || !tenancy.enabled) {
1008
- const { tenancy: _drop, ...rest } = project
1009
- return rest
1010
- }
1011
- return { ...project, tenancy }
1012
- }
1013
-
1014
- /** The tenant column name, defaulted. */
1015
- export const tenantField = (project: StudioProject): string => project.tenancy?.field || 'tenantId'
1016
-
1017
- /** True when `entity` is scoped to a tenant (i.e. tenancy is on and it is not
1018
- * listed as shared). Shared entities stay global reference data. */
1019
- export function isTenantScoped(project: StudioProject, entity: string): boolean {
1020
- if (project.tenancy?.enabled !== true) return false
1021
- return !(project.tenancy.sharedEntities ?? []).includes(entity)
1022
- }
1023
-
1024
- /** Add or replace a scheduled job (matched by `id`). Pass `null` to remove one. */
1025
- export function setJob(project: StudioProject, id: string, job: Omit<ScheduledJob, 'id'> | null): StudioProject {
1026
- const rest = (project.jobs ?? []).filter((j) => j.id !== id)
1027
- const next = job ? [...rest, { ...job, id }] : rest
1028
- return { ...project, jobs: next.length ? next : undefined }
1029
- }
1030
-
1031
- /** Where the generated app deploys. Drives the emitted SvelteKit adapter + config. */
1032
- export type DeployTarget = 'auto' | 'vercel' | 'netlify' | 'cloudflare' | 'node'
1033
-
1034
- /** Localization config: the locales the app ships and which is the default. */
1035
- export type I18nConfig = { enabled: boolean; locales: string[]; defaultLocale?: string }
1036
-
1037
- export type ProjectIssueLevel = 'error' | 'warning'
1038
- export type ProjectIssue = { level: ProjectIssueLevel; message: string; screen?: string; block?: string }
1039
-
1040
- /** One palette entry: a draggable block kind + what it needs to be useful. */
1041
- export type PaletteItem = { kind: BlockKind; label: string; needs?: 'measure' | 'child' }
1042
-
1043
- /** The designer's block palette, in menu order. (Editing is a Grid property, so
1044
- * there's no standalone form block.) */
1045
- export const blockPalette: ReadonlyArray<PaletteItem> = [
1046
- { kind: 'grid', label: 'Grid' },
1047
- { kind: 'chart', label: 'Chart', needs: 'measure' },
1048
- { kind: 'pivot', label: 'Pivot', needs: 'measure' },
1049
- { kind: 'dashboard', label: 'Dashboard' },
1050
- { kind: 'kpi', label: 'KPI tile', needs: 'measure' },
1051
- { kind: 'gauge', label: 'Gauge', needs: 'measure' },
1052
- { kind: 'tree', label: 'Tree' },
1053
- { kind: 'tabs', label: 'Tabs' },
1054
- { kind: 'accordion', label: 'Accordion' },
1055
- { kind: 'master-detail', label: 'Master / detail', needs: 'child' },
1056
- { kind: 'board', label: 'Board' },
1057
- { kind: 'calendar', label: 'Calendar' },
1058
- { kind: 'detail', label: 'Detail page' },
1059
- { kind: 'filter', label: 'Filter panel' },
1060
- { kind: 'record', label: 'Record panel' },
1061
- { kind: 'lookup', label: 'Lookup' },
1062
- ]
1063
-
1064
- // --- helpers ---------------------------------------------------------------
1065
-
1066
- /** A free `${prefix}${n}` id not already in `taken`. */
1067
- function uid(prefix: string, taken: ReadonlySet<string>): string {
1068
- let n = 1
1069
- while (taken.has(`${prefix}${n}`)) n++
1070
- return `${prefix}${n}`
1071
- }
1072
-
1073
- export function entityOf(project: StudioProject, name: string | undefined): EntitySchema | undefined {
1074
- if (name == null) return undefined
1075
- return project.entities.find((e) => e.name === name)
1076
- }
1077
-
1078
- const gridHidden = (f: EntityField): boolean =>
1079
- f.hidden === true || (typeof f.hidden === 'object' && f.hidden.grid === true)
1080
-
1081
- function pickDimension(entity: EntitySchema): string {
1082
- const nonKey = entity.fields.filter((f) => !f.primaryKey)
1083
- // Prefer a low-cardinality dimension: enum, then boolean, then text.
1084
- return (
1085
- nonKey.find((f) => f.type === 'enum')?.field ??
1086
- nonKey.find((f) => f.type === 'boolean')?.field ??
1087
- nonKey.find((f) => f.type === 'text')?.field ??
1088
- nonKey[0]?.field ??
1089
- entity.fields[0]?.field ??
1090
- ''
1091
- )
1092
- }
1093
-
1094
- function pickMeasure(entity: EntitySchema): string | undefined {
1095
- return entity.fields.find((f) => f.type === 'number' && !f.primaryKey)?.field
1096
- }
1097
-
1098
- /** Sensible grid columns for an entity: every non-grid-hidden field, shown. */
1099
- export function gridColumns(entity: EntitySchema): GridColumnConfig[] {
1100
- const pk = entity.idField ?? entity.fields.find((f) => f.primaryKey)?.field
1101
- return entity.fields
1102
- .filter((f) => !gridHidden(f))
1103
- // Hide the raw primary-key column by default (a "co1 / dl2" id column reads
1104
- // as unfinished); everything else is visible. Users can re-enable it.
1105
- .map((f) => ({ field: f.field, show: f.field !== pk, header: f.label }))
1106
- }
1107
-
1108
- /** A default config for a freshly-added block of `kind`, sized for the entity. */
1109
- export function defaultBlockConfig(kind: BlockKind, entity: EntitySchema): BlockConfig {
1110
- switch (kind) {
1111
- case 'grid':
1112
- return { kind, columns: gridColumns(entity), pageSize: 10, selectable: true, sortable: true, filterable: false, editing: 'form', formPresentation: 'modal', density: 'normal', striped: false, cellSelection: false, rowSummaries: false, paginated: true, paginationPosition: 'bottom', pageSizeOptions: [10, 25, 50, 100] }
1113
- case 'form':
1114
- return { kind, presentation: 'modal' }
1115
- case 'chart': {
1116
- const measure = pickMeasure(entity)
1117
- return { kind, dimension: pickDimension(entity), measure, reduce: measure ? 'sum' : 'count', type: 'bar' }
1118
- }
1119
- case 'kpi': {
1120
- const measure = pickMeasure(entity)
1121
- return { kind, label: measure ? `Total ${measure}` : `Total ${entity.label ?? entity.name}`, measure, reduce: measure ? 'sum' : 'count' }
1122
- }
1123
- case 'gauge': {
1124
- const measure = pickMeasure(entity)
1125
- return { kind, label: measure ? `Avg ${measure}` : `${entity.label ?? entity.name} count`, measure, reduce: measure ? 'avg' : 'count', min: 0, max: 100 }
1126
- }
1127
- case 'tree': {
1128
- const label = entity.fields.find((f) => !f.primaryKey && f.type === 'text')?.field ?? entity.fields.find((f) => !f.primaryKey)?.field ?? entity.fields[0]?.field ?? ''
1129
- // A self-referential FK (relation back to this entity) is the natural parent link; else guess by name.
1130
- const parent = entity.fields.find((f) => f.type === 'relation' && f.relation?.entity === entity.name)?.field
1131
- ?? entity.fields.find((f) => /parent/i.test(f.field))?.field ?? ''
1132
- return { kind, labelField: label, parentField: parent }
1133
- }
1134
- case 'tabs':
1135
- return { kind, tabs: [{ label: 'Overview', blocks: [] }, { label: 'Details', blocks: [] }] }
1136
- case 'accordion':
1137
- return { kind, sections: [{ label: 'Section 1', blocks: [] }, { label: 'Section 2', blocks: [] }], multiple: false }
1138
- case 'dashboard':
1139
- return { kind }
1140
- case 'master-detail':
1141
- return { kind, childEntity: '', foreignKey: '' }
1142
- case 'lookup':
1143
- return { kind, field: entity.fields.find((f) => f.type === 'relation')?.field ?? entity.fields[0]?.field ?? '' }
1144
- case 'pivot': {
1145
- const measure = pickMeasure(entity)
1146
- const dim = pickDimension(entity)
1147
- return { kind, rows: dim ? [dim] : [], cols: [], measure, aggregate: measure ? 'sum' : 'count' }
1148
- }
1149
- case 'filter':
1150
- return { kind, fields: pickFacetFields(entity) }
1151
- case 'record':
1152
- return { kind, editable: false }
1153
- case 'board': {
1154
- const enumField = entity.fields.find((f) => f.type === 'enum' && !f.primaryKey)?.field ?? ''
1155
- const titleF = entity.fields.find((f) => f.type === 'text' && !f.primaryKey)?.field ?? entity.fields.find((f) => !f.primaryKey)?.field ?? ''
1156
- const badge = pickMeasure(entity)
1157
- return { kind, groupBy: enumField, titleField: titleF, ...(badge ? { badgeField: badge } : {}) }
1158
- }
1159
- case 'calendar': {
1160
- const dateF = entity.fields.find((f) => (f.type === 'datetime' || f.type === 'date' || f.type === 'dateString') && !f.primaryKey)?.field ?? ''
1161
- const titleF = entity.fields.find((f) => f.type === 'text' && !f.primaryKey)?.field ?? entity.fields.find((f) => !f.primaryKey)?.field ?? ''
1162
- const colorF = entity.fields.find((f) => f.type === 'enum' && !f.primaryKey)?.field
1163
- return { kind, dateField: dateF, titleField: titleF, ...(colorF ? { colorField: colorF } : {}) }
1164
- }
1165
- case 'detail': {
1166
- const nonKey = entity.fields.filter((f) => !f.primaryKey)
1167
- const titleF = nonKey.find((f) => f.type === 'text')?.field ?? nonKey[0]?.field ?? entity.fields[0]?.field ?? ''
1168
- const statusF = nonKey.find((f) => f.type === 'enum')?.field
1169
- const subF = nonKey.find((f) => (f.type === 'text' || f.type === 'relation') && f.field !== titleF)?.field
1170
- const metrics = nonKey.filter((f) => f.type === 'number').slice(0, 3).map((f) => f.field)
1171
- return { kind, titleField: titleF, ...(subF ? { subtitleField: subF } : {}), ...(statusF ? { statusField: statusF } : {}), ...(metrics.length ? { metricFields: metrics } : {}) }
1172
- }
1173
- case 'component':
1174
- // Entity-agnostic - built directly by `addComponentBlock`, never through here.
1175
- return { kind, component: '', props: {} }
1176
- }
1177
- }
1178
-
1179
- /** Low-cardinality, filter-friendly fields (enum, boolean, then text), non-key.
1180
- * Used to seed the faceted Filter panel. Caps at 4 so the panel stays compact. */
1181
- export function pickFacetFields(entity: EntitySchema): string[] {
1182
- return entity.fields
1183
- .filter((f) => !f.primaryKey && (f.type === 'enum' || f.type === 'boolean' || f.type === 'text'))
1184
- .sort((a, b) => facetRank(a.type) - facetRank(b.type))
1185
- .slice(0, 4)
1186
- .map((f) => f.field)
1187
- }
1188
- const facetRank = (t: EntityFieldType): number => (t === 'enum' ? 0 : t === 'boolean' ? 1 : 2)
1189
-
1190
- const DEFAULT_SPAN: Record<BlockKind, 1 | 2 | 3> = {
1191
- grid: 3, form: 1, chart: 2, dashboard: 3, kpi: 1, gauge: 1, tree: 2, tabs: 3, accordion: 3, 'master-detail': 3, lookup: 1, pivot: 3, filter: 1, record: 1, board: 3, calendar: 3, detail: 3, component: 1,
1192
- }
1193
-
1194
- function makeBlock(kind: BlockKind, entity: EntitySchema, taken: ReadonlySet<string>): Block {
1195
- return { id: uid(`${kind}-`, taken), span: DEFAULT_SPAN[kind], config: defaultBlockConfig(kind, entity) }
1196
- }
1197
-
1198
- // --- Tabs container ops (pure; the designer builds a new config + updateBlock) ---
1199
-
1200
- /** Append a tab to a Tabs container. */
1201
- export function addTab(cfg: TabsConfig, label?: string): TabsConfig {
1202
- return { ...cfg, tabs: [...cfg.tabs, { label: label ?? `Tab ${cfg.tabs.length + 1}`, blocks: [] }] }
1203
- }
1204
- /** Remove a tab by index (keeps at least one). */
1205
- export function removeTab(cfg: TabsConfig, index: number): TabsConfig {
1206
- if (cfg.tabs.length <= 1) return cfg
1207
- return { ...cfg, tabs: cfg.tabs.filter((_, i) => i !== index) }
1208
- }
1209
- /** Rename a tab by index. */
1210
- export function renameTab(cfg: TabsConfig, index: number, label: string): TabsConfig {
1211
- return { ...cfg, tabs: cfg.tabs.map((t, i) => (i === index ? { ...t, label } : t)) }
1212
- }
1213
- /** Add a child block (default config for `kind`) to a tab. Only `TAB_CHILD_KINDS` are allowed. */
1214
- export function addTabBlock(cfg: TabsConfig, index: number, kind: BlockKind, entity: EntitySchema): TabsConfig {
1215
- if (!TAB_CHILD_KINDS.includes(kind)) return cfg
1216
- const taken = new Set<string>()
1217
- cfg.tabs.forEach((t) => flattenBlocks(t.blocks).forEach((b) => taken.add(b.id)))
1218
- const block: Block = { id: uid(`${kind}-`, taken), span: DEFAULT_SPAN[kind], config: defaultBlockConfig(kind, entity) }
1219
- return { ...cfg, tabs: cfg.tabs.map((t, i) => (i === index ? { ...t, blocks: [...t.blocks, block] } : t)) }
1220
- }
1221
- /** Remove a child block from a tab by id. */
1222
- export function removeTabBlock(cfg: TabsConfig, index: number, blockId: string): TabsConfig {
1223
- return { ...cfg, tabs: cfg.tabs.map((t, i) => (i === index ? { ...t, blocks: t.blocks.filter((b) => b.id !== blockId) } : t)) }
1224
- }
1225
-
1226
- /** Build a UI-kit component child block for a container section (Tabs / Accordion),
1227
- * seeded with the registry's default props + content. Returns null for an unknown
1228
- * component key. Entity-agnostic - components need no entity. */
1229
- function makeComponentChild(componentKey: string, taken: ReadonlySet<string>): Block | null {
1230
- const spec = uiComponentSpec(componentKey)
1231
- if (!spec) return null
1232
- const props: Record<string, unknown> = {}
1233
- for (const p of spec.props) if (p.default != null) props[p.key] = p.default
1234
- if (spec.hasContent) props._content = spec.contentDefault ?? spec.label
1235
- const set = new Set(taken)
1236
- return { id: uid('component-', set), span: DEFAULT_SPAN.component, config: { kind: 'component', component: componentKey, props } }
1237
- }
1238
- /** Add a specific UI-kit component (by registry key) to a tab. */
1239
- export function addTabComponent(cfg: TabsConfig, index: number, componentKey: string): TabsConfig {
1240
- const taken = new Set<string>()
1241
- cfg.tabs.forEach((t) => flattenBlocks(t.blocks).forEach((b) => taken.add(b.id)))
1242
- const block = makeComponentChild(componentKey, taken)
1243
- if (!block) return cfg
1244
- return { ...cfg, tabs: cfg.tabs.map((t, i) => (i === index ? { ...t, blocks: [...t.blocks, block] } : t)) }
1245
- }
1246
-
1247
- // --- Accordion container (mirrors the Tabs helpers) ------------------------
1248
- /** Append a section (collapsible panel) to an accordion. */
1249
- export function addAccordionSection(cfg: AccordionConfig, label?: string): AccordionConfig {
1250
- return { ...cfg, sections: [...cfg.sections, { label: label ?? `Section ${cfg.sections.length + 1}`, blocks: [] }] }
1251
- }
1252
- /** Remove a section by index (keeps at least one). */
1253
- export function removeAccordionSection(cfg: AccordionConfig, index: number): AccordionConfig {
1254
- if (cfg.sections.length <= 1) return cfg
1255
- return { ...cfg, sections: cfg.sections.filter((_, i) => i !== index) }
1256
- }
1257
- /** Rename a section by index. */
1258
- export function renameAccordionSection(cfg: AccordionConfig, index: number, label: string): AccordionConfig {
1259
- return { ...cfg, sections: cfg.sections.map((s, i) => (i === index ? { ...s, label } : s)) }
1260
- }
1261
- /** Toggle single- vs multiple-open behaviour. */
1262
- export function setAccordionMultiple(cfg: AccordionConfig, multiple: boolean): AccordionConfig {
1263
- return { ...cfg, multiple }
1264
- }
1265
- /** Add a display-block child (default config for `kind`) to a section. Only CONTAINER_CHILD_KINDS. */
1266
- export function addAccordionBlock(cfg: AccordionConfig, index: number, kind: BlockKind, entity: EntitySchema): AccordionConfig {
1267
- if (!CONTAINER_CHILD_KINDS.includes(kind)) return cfg
1268
- const taken = new Set<string>()
1269
- cfg.sections.forEach((s) => flattenBlocks(s.blocks).forEach((b) => taken.add(b.id)))
1270
- const block: Block = { id: uid(`${kind}-`, taken), span: DEFAULT_SPAN[kind], config: defaultBlockConfig(kind, entity) }
1271
- return { ...cfg, sections: cfg.sections.map((s, i) => (i === index ? { ...s, blocks: [...s.blocks, block] } : s)) }
1272
- }
1273
- /** Add a specific UI-kit component (by registry key) to a section. */
1274
- export function addAccordionComponent(cfg: AccordionConfig, index: number, componentKey: string): AccordionConfig {
1275
- const taken = new Set<string>()
1276
- cfg.sections.forEach((s) => flattenBlocks(s.blocks).forEach((b) => taken.add(b.id)))
1277
- const block = makeComponentChild(componentKey, taken)
1278
- if (!block) return cfg
1279
- return { ...cfg, sections: cfg.sections.map((s, i) => (i === index ? { ...s, blocks: [...s.blocks, block] } : s)) }
1280
- }
1281
- /** Remove a child block from a section by id. */
1282
- export function removeAccordionBlock(cfg: AccordionConfig, index: number, blockId: string): AccordionConfig {
1283
- return { ...cfg, sections: cfg.sections.map((s, i) => (i === index ? { ...s, blocks: s.blocks.filter((b) => b.id !== blockId) } : s)) }
1284
- }
1285
-
1286
- /** A default screen for an entity: a grid (editing via a popup form by default). */
1287
- export function defaultScreenFor(entity: EntitySchema): Screen {
1288
- const grid = makeBlock('grid', entity, new Set<string>())
1289
- return { id: entity.name, entity: entity.name, title: entity.label ?? entity.name, route: entity.name, blocks: [grid] }
1290
- }
1291
-
1292
- /** A new project from a set of entities: one default screen each, in-memory. */
1293
- export function createProject(entities: EntitySchema[], opts: { title?: string; dataSource?: DataSourceKind } = {}): StudioProject {
1294
- return {
1295
- title: opts.title ?? 'My Studio App',
1296
- entities: [...entities],
1297
- screens: entities.map(defaultScreenFor),
1298
- dataSource: opts.dataSource ?? 'memory',
1299
- }
1300
- }
1301
-
1302
- // --- immutable ops ---------------------------------------------------------
1303
-
1304
- function mapScreen(project: StudioProject, screenId: string, fn: (s: Screen) => Screen): StudioProject {
1305
- return { ...project, screens: project.screens.map((s) => (s.id === screenId ? fn(s) : s)) }
1306
- }
1307
-
1308
- export function addBlock(project: StudioProject, screenId: string, kind: BlockKind): StudioProject {
1309
- return mapScreen(project, screenId, (s) => {
1310
- const entity = entityOf(project, s.entity)
1311
- if (!entity) return s
1312
- const taken = new Set(s.blocks.map((b) => b.id))
1313
- return { ...s, blocks: [...s.blocks, makeBlock(kind, entity, taken)] }
1314
- })
1315
- }
1316
-
1317
- /** Insert a new block at `index` (drag-drop from the palette). Clamps to range. */
1318
- export function addBlockAt(project: StudioProject, screenId: string, kind: BlockKind, index: number): StudioProject {
1319
- return mapScreen(project, screenId, (s) => {
1320
- const entity = entityOf(project, s.entity)
1321
- if (!entity) return s
1322
- const taken = new Set(s.blocks.map((b) => b.id))
1323
- const block = makeBlock(kind, entity, taken)
1324
- const blocks = [...s.blocks]
1325
- blocks.splice(Math.max(0, Math.min(index, blocks.length)), 0, block)
1326
- return { ...s, blocks }
1327
- })
1328
- }
1329
-
1330
- /** Add a UI-kit component block (see `ui-components.ts`'s `UI_COMPONENT_REGISTRY`)
1331
- * to a screen. Unlike `addBlock`/`addBlockAt`, this needs no entity - it works on
1332
- * any screen, including a freestanding one, or mixed onto an entity-bound one. */
1333
- export function addComponentBlock(project: StudioProject, screenId: string, componentKey: string, defaultProps: Record<string, unknown> = {}, index?: number): StudioProject {
1334
- return mapScreen(project, screenId, (s) => {
1335
- const taken = new Set(flattenBlocks(s.blocks).map((b) => b.id))
1336
- const name = uniqueComponentName(componentKey, s)
1337
- const block: Block = { id: uid('component-', taken), span: DEFAULT_SPAN.component, config: { kind: 'component', component: componentKey, props: { ...defaultProps }, name } }
1338
- const blocks = [...s.blocks]
1339
- blocks.splice(index != null ? Math.max(0, Math.min(index, blocks.length)) : blocks.length, 0, block)
1340
- return { ...s, blocks }
1341
- })
1342
- }
1343
-
1344
- /** A valid, unique JS identifier for a component handle on a screen, e.g. `button1`.
1345
- * Handles are the named objects code reaches (btn.setLabel(...), btn.onclick = ...). */
1346
- export function componentHandleName(cfg: ComponentConfig): string {
1347
- const raw = (cfg.name ?? cfg.component).trim()
1348
- const id = raw.replace(/[^A-Za-z0-9_$]/g, '_').replace(/^([0-9])/, '_$1')
1349
- return id || 'el'
1350
- }
1351
- function uniqueComponentName(componentKey: string, screen: Screen): string {
1352
- const taken = new Set(
1353
- flattenBlocks(screen.blocks)
1354
- .filter((b) => b.config.kind === 'component')
1355
- .map((b) => componentHandleName(b.config as ComponentConfig)),
1356
- )
1357
- let n = 1
1358
- while (taken.has(`${componentKey}${n}`)) n++
1359
- return `${componentKey}${n}`
1360
- }
1361
-
1362
- /** Rename a component's handle (the variable code uses to reach it). */
1363
- export function setComponentName(project: StudioProject, screenId: string, blockId: string, name: string): StudioProject {
1364
- return mapScreen(project, screenId, (s) => ({
1365
- ...s,
1366
- blocks: mapBlockTree(s.blocks, blockId, (b) => (b.config.kind === 'component' ? { ...b, config: { ...b.config, name: name.trim() || undefined } } : b)),
1367
- }))
1368
- }
1369
-
1370
- /** True when a component has at least one data-bound prop. */
1371
- export function componentHasBindings(cfg: ComponentConfig): boolean {
1372
- return !!cfg.bindings && Object.keys(cfg.bindings).length > 0
1373
- }
1374
- /** Bind (or, with `binding: null`, unbind) one component prop to the screen's data. */
1375
- export function setComponentBinding(project: StudioProject, screenId: string, blockId: string, propKey: string, binding: ComponentBinding | null): StudioProject {
1376
- return mapScreen(project, screenId, (s) => ({
1377
- ...s,
1378
- blocks: mapBlockTree(s.blocks, blockId, (b) => {
1379
- if (b.config.kind !== 'component') return b
1380
- const bindings = { ...(b.config.bindings ?? {}) }
1381
- if (binding) bindings[propKey] = binding
1382
- else delete bindings[propKey]
1383
- return { ...b, config: { ...b.config, bindings: Object.keys(bindings).length ? bindings : undefined } }
1384
- }),
1385
- }))
1386
- }
1387
-
1388
- /** Apply `fn` to the block with `id` anywhere in the tree (top level or nested in a Tabs / Accordion container). */
1389
- function mapBlockTree(blocks: Block[], id: string, fn: (b: Block) => Block): Block[] {
1390
- return blocks.map((b) => {
1391
- if (b.id === id) return fn(b)
1392
- if (b.config.kind === 'tabs') {
1393
- return { ...b, config: { ...b.config, tabs: b.config.tabs.map((t) => ({ ...t, blocks: mapBlockTree(t.blocks, id, fn) })) } }
1394
- }
1395
- if (b.config.kind === 'accordion') {
1396
- return { ...b, config: { ...b.config, sections: b.config.sections.map((s) => ({ ...s, blocks: mapBlockTree(s.blocks, id, fn) })) } }
1397
- }
1398
- return b
1399
- })
1400
- }
1401
- /** Remove the block with `id` anywhere in the tree (top level or nested in a Tabs / Accordion container). */
1402
- function removeBlockTree(blocks: Block[], id: string): Block[] {
1403
- return blocks
1404
- .filter((b) => b.id !== id)
1405
- .map((b) =>
1406
- b.config.kind === 'tabs' ? { ...b, config: { ...b.config, tabs: b.config.tabs.map((t) => ({ ...t, blocks: removeBlockTree(t.blocks, id) })) } }
1407
- : b.config.kind === 'accordion' ? { ...b, config: { ...b.config, sections: b.config.sections.map((s) => ({ ...s, blocks: removeBlockTree(s.blocks, id) })) } }
1408
- : b,
1409
- )
1410
- }
1411
-
1412
- export function removeBlock(project: StudioProject, screenId: string, blockId: string): StudioProject {
1413
- return mapScreen(project, screenId, (s) => ({ ...s, blocks: removeBlockTree(s.blocks, blockId) }))
1414
- }
1415
-
1416
- /** Clone a block (config + width/height) with a fresh id, inserted right after it. */
1417
- export function duplicateBlock(project: StudioProject, screenId: string, blockId: string): StudioProject {
1418
- return mapScreen(project, screenId, (s) => {
1419
- const idx = s.blocks.findIndex((b) => b.id === blockId)
1420
- if (idx < 0) return s
1421
- const src = s.blocks[idx]!
1422
- // JSON clone (not structuredClone): the config is always JSON-safe, and this
1423
- // also unwraps any Svelte reactive $state proxy, which structuredClone rejects.
1424
- const clone: Block = { ...src, id: uid(`${src.config.kind}-`, new Set(s.blocks.map((b) => b.id))), config: JSON.parse(JSON.stringify(src.config)) as BlockConfig }
1425
- return { ...s, blocks: [...s.blocks.slice(0, idx + 1), clone, ...s.blocks.slice(idx + 1)] }
1426
- })
1427
- }
1428
-
1429
- /** Move a block one slot up (`-1`) or down (`+1`). No-op at the ends. */
1430
- export function moveBlock(project: StudioProject, screenId: string, blockId: string, dir: -1 | 1): StudioProject {
1431
- return mapScreen(project, screenId, (s) => {
1432
- const i = s.blocks.findIndex((b) => b.id === blockId)
1433
- const j = i + dir
1434
- if (i < 0 || j < 0 || j >= s.blocks.length) return s
1435
- const blocks = [...s.blocks]
1436
- ;[blocks[i], blocks[j]] = [blocks[j]!, blocks[i]!]
1437
- return { ...s, blocks }
1438
- })
1439
- }
1440
-
1441
- /** Reorder a block to an explicit index (drag-drop). Clamps to range. */
1442
- export function reorderBlock(project: StudioProject, screenId: string, blockId: string, toIndex: number): StudioProject {
1443
- return mapScreen(project, screenId, (s) => {
1444
- const from = s.blocks.findIndex((b) => b.id === blockId)
1445
- if (from < 0) return s
1446
- const blocks = [...s.blocks]
1447
- const [moved] = blocks.splice(from, 1)
1448
- blocks.splice(Math.max(0, Math.min(toIndex, blocks.length)), 0, moved!)
1449
- return { ...s, blocks }
1450
- })
1451
- }
1452
-
1453
- /** Patch a block's config (merged), span/height, and/or appearance `style` (merged;
1454
- * keys set to undefined are cleared). */
1455
- export function updateBlock(
1456
- project: StudioProject,
1457
- screenId: string,
1458
- blockId: string,
1459
- patch: { span?: 1 | 2 | 3; colSpan?: number; height?: number; config?: Partial<BlockConfig>; style?: Partial<BlockStyle>; className?: string },
1460
- ): StudioProject {
1461
- return mapScreen(project, screenId, (s) => ({
1462
- ...s,
1463
- blocks: mapBlockTree(s.blocks, blockId, (b) => ({
1464
- ...b,
1465
- span: patch.span ?? b.span,
1466
- colSpan: patch.colSpan ?? b.colSpan,
1467
- height: patch.height ?? b.height,
1468
- style: patch.style ? mergeBlockStyle(b.style, patch.style) : b.style,
1469
- className: patch.className !== undefined ? (patch.className.trim() || undefined) : b.className,
1470
- config: patch.config ? ({ ...b.config, ...patch.config } as BlockConfig) : b.config,
1471
- })),
1472
- }))
1473
- }
1474
-
1475
- /** Replace an entity's schema (e.g. after field edits), keeping its screens. */
1476
- export function updateEntity(project: StudioProject, name: string, schema: EntitySchema): StudioProject {
1477
- const entities = project.entities.map((e) => (e.name === name ? schema : e))
1478
- if (name === schema.name) return { ...project, entities }
1479
- // Renamed: retarget its screens AND any master-detail block pointing at it.
1480
- const screens = project.screens.map((s) => {
1481
- const blocks = s.blocks.map((b) =>
1482
- b.config.kind === 'master-detail' && b.config.childEntity === name
1483
- ? { ...b, config: { ...b.config, childEntity: schema.name } }
1484
- : b,
1485
- )
1486
- return { ...s, entity: s.entity === name ? schema.name : s.entity, blocks }
1487
- })
1488
- return { ...project, entities, screens }
1489
- }
1490
-
1491
- /** Add an entity + its default screen. */
1492
- export function addEntity(project: StudioProject, schema: EntitySchema): StudioProject {
1493
- if (entityOf(project, schema.name)) return project
1494
- return { ...project, entities: [...project.entities, schema], screens: [...project.screens, defaultScreenFor(schema)] }
1495
- }
1496
-
1497
- /** Remove an entity and every screen bound to it. */
1498
- export function removeEntity(project: StudioProject, name: string): StudioProject {
1499
- return {
1500
- ...project,
1501
- entities: project.entities.filter((e) => e.name !== name),
1502
- screens: project.screens.filter((s) => s.entity !== name),
1503
- }
1504
- }
1505
-
1506
- /** Append a screen, making its id AND route unique against the existing set. */
1507
- function appendScreen(project: StudioProject, base: Screen, keyBase: string): StudioProject {
1508
- const ids = new Set(project.screens.map((s) => s.id))
1509
- const routes = new Set(project.screens.map((s) => s.route))
1510
- const id = ids.has(base.id) ? uid(`${keyBase}-`, ids) : base.id
1511
- const route = routes.has(base.route) ? uid(`${base.route}-`, routes) : base.route
1512
- return { ...project, screens: [...project.screens, { ...base, id, route }] }
1513
- }
1514
-
1515
- export function addScreen(project: StudioProject, entity: string): StudioProject {
1516
- const schema = entityOf(project, entity)
1517
- if (!schema) return project
1518
- return appendScreen(project, defaultScreenFor(schema), entity)
1519
- }
1520
-
1521
- export function removeScreen(project: StudioProject, screenId: string): StudioProject {
1522
- return { ...project, screens: project.screens.filter((s) => s.id !== screenId) }
1523
- }
1524
-
1525
- export function updateScreen(
1526
- project: StudioProject,
1527
- screenId: string,
1528
- // `renderMode` is patchable here so SSR output is reachable without the
1529
- // designer - the CLI and the MCP tools drive the model through this function.
1530
- patch: Partial<Pick<Screen, 'title' | 'route' | 'entity' | 'nav' | 'actions' | 'className' | 'renderMode'>>,
1531
- ): StudioProject {
1532
- return mapScreen(project, screenId, (s) => ({ ...s, ...patch }))
1533
- }
1534
-
1535
- // ---- Screen layout: 12-column grid (default) or a docking workspace ----------
1536
-
1537
- /** The screen's layout engine. Defaults to `'grid'` (the 12-column responsive grid). */
1538
- export function screenLayoutOf(screen: Screen): ScreenLayout {
1539
- return screen.layout ?? 'grid'
1540
- }
1541
-
1542
- /** A short title for a block's dock pane. */
1543
- function dockPaneTitle(block: Block): string {
1544
- const c = block.config as { kind: string; label?: string; title?: string; name?: string; component?: string }
1545
- const byKind: Record<string, string> = {
1546
- grid: 'Grid', form: 'Form', chart: 'Chart', dashboard: 'Dashboard', tree: 'Tree', pivot: 'Pivot',
1547
- filter: 'Filters', record: 'Record', detail: 'Detail', board: 'Board', calendar: 'Calendar',
1548
- 'master-detail': 'Master / detail', lookup: 'Lookup', tabs: 'Tabs', accordion: 'Accordion',
1549
- kpi: 'KPI', gauge: 'Gauge', component: 'Component',
1550
- }
1551
- return c.label || c.title || c.name || c.component || byKind[c.kind] || 'Panel'
1552
- }
1553
-
1554
- /** Every pane id present anywhere in a dock workspace (main tree + floating + auto-hidden). */
1555
- export function dockPaneIds(state: DockManagerState): Set<string> {
1556
- const ids = new Set<string>()
1557
- const walk = (n: DockNode) => { if (n.type === 'tabs') for (const p of n.panes) ids.add(p.id); else for (const c of n.children) walk(c) }
1558
- if (state.main) walk(state.main)
1559
- for (const w of state.floating) for (const p of w.leaf.panes) ids.add(p.id)
1560
- for (const e of state.autoHide) for (const p of e.leaf.panes) ids.add(p.id)
1561
- return ids
1562
- }
1563
-
1564
- /**
1565
- * Smart auto-layout: seed a docking workspace from a screen's blocks by role -
1566
- * filters dock left, record / detail panels dock right, KPIs / gauges strip across
1567
- * the top, and the main content (grid / board / calendar / chart / ...) fills the
1568
- * centre. Pane ids equal block ids. Runtime float / pin / pop-out is layered on later.
1569
- */
1570
- export function buildDockLayout(screen: Screen): DockManagerState {
1571
- const taken = new Set<string>(screen.blocks.map((b) => b.id))
1572
- const genId = (p: string) => { const id = uid(p, taken); taken.add(id); return id }
1573
- const paneFor = (b: Block): DockPane => ({ id: b.id, title: dockPaneTitle(b), closable: true })
1574
- const roleOf = (b: Block): 'left' | 'center' | 'right' | 'top' => {
1575
- switch (b.config.kind) {
1576
- case 'filter': return 'left'
1577
- case 'record': case 'detail': return 'right'
1578
- case 'kpi': case 'gauge': return 'top'
1579
- default: return 'center'
1580
- }
1581
- }
1582
- const groupOf = (direction: 'row' | 'column', children: DockNode[], sizes: number[]): DockNode => ({ type: 'group', id: genId('dg-'), direction, children, sizes })
1583
- const norm = (ws: number[]): number[] => { const s = ws.reduce((a, b) => a + b, 0) || 1; return ws.map((w) => w / s) }
1584
- // Each block gets its OWN leaf, so split view can size every block independently
1585
- // (grouping them into one tabs leaf would trap them as tabs - unresizable when
1586
- // the manager is locked). Multiple blocks in a bucket stack along the given axis.
1587
- const oneLeaf = (b: Block): DockTabs => ({ type: 'tabs', id: genId('dt-'), panes: [paneFor(b)], active: 0 })
1588
- const stackOf = (bs: Block[], direction: 'row' | 'column'): DockNode =>
1589
- bs.length === 1 ? oneLeaf(bs[0]!) : groupOf(direction, bs.map(oneLeaf), norm(bs.map(() => 1)))
1590
- // A forced split orientation (split-view setting) ignores roles and lays ALL
1591
- // blocks out as equal panes in one row / column.
1592
- const forced = splitOpts(screen).orientation
1593
- if (forced !== 'auto' && screen.blocks.length) {
1594
- return { main: stackOf(screen.blocks, forced), floating: [], autoHide: [] }
1595
- }
1596
- const buckets: Record<'left' | 'center' | 'right' | 'top', Block[]> = { left: [], center: [], right: [], top: [] }
1597
- for (const b of screen.blocks) buckets[roleOf(b)].push(b)
1598
- const rowChildren: DockNode[] = []
1599
- const rowWeights: number[] = []
1600
- if (buckets.left.length) { rowChildren.push(stackOf(buckets.left, 'column')); rowWeights.push(1) }
1601
- if (buckets.center.length) { rowChildren.push(stackOf(buckets.center, 'column')); rowWeights.push(3) }
1602
- if (buckets.right.length) { rowChildren.push(stackOf(buckets.right, 'column')); rowWeights.push(1) }
1603
- const row: DockNode | null = rowChildren.length === 0 ? null : rowChildren.length === 1 ? rowChildren[0]! : groupOf('row', rowChildren, norm(rowWeights))
1604
- let main: DockNode
1605
- if (buckets.top.length && row) main = groupOf('column', [stackOf(buckets.top, 'row'), row], [0.28, 0.72])
1606
- else if (buckets.top.length) main = stackOf(buckets.top, 'row')
1607
- else if (row) main = row
1608
- else main = { type: 'tabs', id: genId('dt-'), panes: [], active: 0 }
1609
- return { main, floating: [], autoHide: [] }
1610
- }
1611
-
1612
- /** Every group / tabs node id in a workspace (to keep freshly generated ids unique). */
1613
- function collectDockNodeIds(state: DockManagerState): string[] {
1614
- const ids: string[] = []
1615
- const walk = (n: DockNode) => { ids.push(n.id); if (n.type === 'group') for (const c of n.children) walk(c) }
1616
- if (state.main) walk(state.main)
1617
- for (const w of state.floating) { ids.push(w.id); walk(w.leaf) }
1618
- for (const e of state.autoHide) { ids.push(e.id); walk(e.leaf) }
1619
- return ids
1620
- }
1621
-
1622
- /** Append a pane to the main area's first tabs leaf (creating one if the area is empty). */
1623
- function addDockPaneToMain(state: DockManagerState, p: DockPane, genId: () => string): DockManagerState {
1624
- const main = state.main
1625
- if (!main) return { ...state, main: { type: 'tabs', id: genId(), panes: [p], active: 0 } }
1626
- let added = false
1627
- const add = (n: DockNode): DockNode => {
1628
- if (added) return n
1629
- if (n.type === 'tabs') { added = true; return { ...n, panes: [...n.panes, p], active: n.panes.length } }
1630
- return { ...n, children: n.children.map(add) }
1631
- }
1632
- const nextMain = add(main)
1633
- if (added) return { ...state, main: nextMain }
1634
- // No tabs leaf anywhere: put main + a new tabs side by side.
1635
- return { ...state, main: { type: 'group', id: genId(), direction: 'row', children: [main, { type: 'tabs', id: genId(), panes: [p], active: 0 }], sizes: [0.7, 0.3] } }
1636
- }
1637
-
1638
- /** Remove a pane anywhere in a workspace; collapse emptied tabs + single-child groups. */
1639
- function stripDockPane(state: DockManagerState, paneId: string): DockManagerState {
1640
- const strip = (n: DockNode): DockNode | null => {
1641
- if (n.type === 'tabs') { const panes = n.panes.filter((p: DockPane) => p.id !== paneId); return panes.length ? { ...n, panes, active: Math.min(n.active, panes.length - 1) } : null }
1642
- const children = n.children.map(strip).filter((c: DockNode | null): c is DockNode => c !== null)
1643
- if (children.length === 0) return null
1644
- if (children.length === 1) return children[0]!
1645
- return { ...n, children, sizes: children.map(() => 1 / children.length) }
1646
- }
1647
- const main = state.main ? strip(state.main) : null
1648
- const floating = state.floating.map((w: DockManagerState['floating'][number]) => ({ ...w, leaf: { ...w.leaf, panes: w.leaf.panes.filter((p: DockPane) => p.id !== paneId) } })).filter((w: DockManagerState['floating'][number]) => w.leaf.panes.length > 0)
1649
- const autoHide = state.autoHide.map((e: DockManagerState['autoHide'][number]) => ({ ...e, leaf: { ...e.leaf, panes: e.leaf.panes.filter((p: DockPane) => p.id !== paneId) } })).filter((e: DockManagerState['autoHide'][number]) => e.leaf.panes.length > 0)
1650
- return { ...state, main, floating, autoHide }
1651
- }
1652
-
1653
- /** Incrementally reconcile a dock workspace to the screen's blocks - add a pane for each new
1654
- * block, strip panes for removed blocks - WITHOUT rebuilding, so the user's arrangement
1655
- * (splits / floats / pins) is preserved. Seeds a fresh workspace if none exists. */
1656
- export function syncDockPanes(screen: Screen): Screen {
1657
- if (!isPaneLayout(screenLayoutOf(screen))) return screen
1658
- if (!screen.dock) return { ...screen, dock: buildDockLayout(screen) }
1659
- const blockIds = new Set(screen.blocks.map((b) => b.id))
1660
- const present = dockPaneIds(screen.dock)
1661
- const missing = screen.blocks.filter((b) => !present.has(b.id))
1662
- const extra = [...present].filter((id) => !blockIds.has(id))
1663
- if (missing.length === 0 && extra.length === 0) return screen
1664
- let dock = screen.dock
1665
- for (const id of extra) dock = stripDockPane(dock, id)
1666
- const taken = new Set<string>([...blockIds, ...collectDockNodeIds(dock)])
1667
- const genId = () => { const id = uid('dt-', taken); taken.add(id); return id }
1668
- for (const b of missing) dock = addDockPaneToMain(dock, { id: b.id, title: dockPaneTitle(b), closable: true }, genId)
1669
- return { ...screen, dock }
1670
- }
1671
-
1672
- /** Rebuild the dock workspace if its panes no longer match the screen's blocks (a safety
1673
- * net after block add / delete while the visual editor hasn't reconciled incrementally). */
1674
- export function reconcileDock(screen: Screen): Screen {
1675
- if (!isPaneLayout(screenLayoutOf(screen))) return screen
1676
- const blockIds = new Set(screen.blocks.map((b) => b.id))
1677
- const paneIds = screen.dock ? dockPaneIds(screen.dock) : new Set<string>()
1678
- const same = !!screen.dock && blockIds.size === paneIds.size && [...blockIds].every((id) => paneIds.has(id))
1679
- return same ? screen : { ...screen, dock: buildDockLayout(screen) }
1680
- }
1681
-
1682
- /** Switch a screen between the 12-column grid and a docking workspace. Switching to
1683
- * `'dock'` seeds a workspace (smart auto-layout) if none exists yet. */
1684
- export function setScreenLayout(project: StudioProject, screenId: string, layout: ScreenLayout): StudioProject {
1685
- // Both pane layouts (split / dock) back onto a DockManagerState; canvas seeds
1686
- // explicit cell coordinates. Switching split<->dock preserves the existing
1687
- // arrangement, but coming FROM a non-pane layout (grid / stack / canvas) seeds a
1688
- // fresh one - so a stale arrangement never carries over into a new workspace.
1689
- return mapScreen(project, screenId, (s) => {
1690
- if (isPaneLayout(layout)) {
1691
- const dock = isPaneLayout(screenLayoutOf(s)) && s.dock ? s.dock : buildDockLayout(s)
1692
- return { ...s, layout, dock }
1693
- }
1694
- if (layout === 'canvas') return { ...s, layout, canvas: s.canvas ?? buildCanvasLayout(s) }
1695
- return { ...s, layout }
1696
- })
1697
- }
1698
-
1699
- // --- free-form canvas geometry ----------------------------------------------
1700
-
1701
- const clampInt = (n: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, Math.round(n)))
1702
-
1703
- /** A sensible default row height (in canvas rows) for a block kind - tall for
1704
- * data views, short for a single stat. */
1705
- function defaultCanvasRows(block: Block): number {
1706
- const kind = block.config.kind
1707
- if (kind === 'kpi' || kind === 'gauge') return 3
1708
- if (kind === 'filter') return 4
1709
- if (kind === 'chart' || kind === 'record' || kind === 'detail') return 6
1710
- return 8 // grid / board / pivot / tree / dashboard / master-detail / form / etc.
1711
- }
1712
-
1713
- /** Seed canvas rects for every block: flow them two-up (half-width each) down the
1714
- * page, so switching to canvas gives a reasonable starting arrangement. */
1715
- export function buildCanvasLayout(screen: Screen): Record<string, CanvasRect> {
1716
- const cols = canvasOpts(screen).cols
1717
- const out: Record<string, CanvasRect> = {}
1718
- let row = 0
1719
- let col = 0
1720
- let rowMax = 0
1721
- for (const b of screen.blocks) {
1722
- const colSpan = Math.max(1, Math.round(cols / 2))
1723
- const rowSpan = defaultCanvasRows(b)
1724
- if (col + colSpan > cols) { col = 0; row += rowMax; rowMax = 0 }
1725
- out[b.id] = { col, row, colSpan, rowSpan }
1726
- col += colSpan
1727
- rowMax = Math.max(rowMax, rowSpan)
1728
- }
1729
- return out
1730
- }
1731
-
1732
- /** The stored rect for a block, or a computed default (so a block added after the
1733
- * layout was seeded still lands somewhere sane). */
1734
- export function canvasRectOf(screen: Screen, blockId: string): CanvasRect {
1735
- const stored = screen.canvas?.[blockId]
1736
- const cols = canvasOpts(screen).cols
1737
- if (stored) {
1738
- // Clamp a stored rect to the current column count (it may have been placed
1739
- // when the canvas had more columns).
1740
- const colSpan = Math.max(1, Math.min(stored.colSpan, cols))
1741
- return { ...stored, colSpan, col: Math.max(0, Math.min(stored.col, cols - colSpan)) }
1742
- }
1743
- return buildCanvasLayout(screen)[blockId] ?? { col: 0, row: 0, colSpan: Math.max(1, Math.round(cols / 2)), rowSpan: 8 }
1744
- }
1745
-
1746
- /** Move / resize a block on the canvas. Values are clamped to the screen's column
1747
- * count (col in-grid, colSpan 1..cols; row >= 0, rowSpan >= 1). */
1748
- export function setCanvasRect(project: StudioProject, screenId: string, blockId: string, rect: Partial<CanvasRect>): StudioProject {
1749
- return mapScreen(project, screenId, (s) => {
1750
- const cols = canvasOpts(s).cols
1751
- const cur = canvasRectOf(s, blockId)
1752
- const colSpan = clampInt(rect.colSpan ?? cur.colSpan, 1, cols)
1753
- const col = clampInt(rect.col ?? cur.col, 0, cols - colSpan)
1754
- const rowSpan = Math.max(1, clampInt(rect.rowSpan ?? cur.rowSpan, 1, 999))
1755
- const row = Math.max(0, clampInt(rect.row ?? cur.row, 0, 999))
1756
- return { ...s, canvas: { ...s.canvas, [blockId]: { col, row, colSpan, rowSpan } } }
1757
- })
1758
- }
1759
-
1760
- /** Named starting arrangements for the 12-column grid - a one-click way to set
1761
- * every block's column span to a common pattern instead of picking each by hand. */
1762
- export type GridPreset = 'full' | 'two-col' | 'three-col' | 'sidebar' | 'kpi-row'
1763
-
1764
- const GRID_PRESET_LABELS: Record<GridPreset, string> = {
1765
- full: 'Full width',
1766
- 'two-col': 'Two columns',
1767
- 'three-col': 'Three columns',
1768
- sidebar: 'Sidebar + main',
1769
- 'kpi-row': 'KPI row + full',
1770
- }
1771
- export function gridPresetLabel(preset: GridPreset): string { return GRID_PRESET_LABELS[preset] }
1772
- export const GRID_PRESETS: ReadonlyArray<GridPreset> = ['full', 'two-col', 'three-col', 'sidebar', 'kpi-row']
1773
-
1774
- /** Small, wide KPI-like blocks that a "KPI row" preset lays out as a strip. */
1775
- const KPI_ROW_KINDS: ReadonlyArray<BlockKind> = ['kpi', 'gauge']
1776
-
1777
- /** Apply a grid preset: rewrite the top-level blocks' `colSpan` to the pattern.
1778
- * Only touches the current screen's own blocks (nested container children keep
1779
- * their spans). A no-op for a screen with no blocks. */
1780
- export function applyGridPreset(project: StudioProject, screenId: string, preset: GridPreset): StudioProject {
1781
- return mapScreen(project, screenId, (s) => {
1782
- if (s.blocks.length === 0) return s
1783
- const span = (i: number, b: Block): number => {
1784
- switch (preset) {
1785
- case 'full': return 12
1786
- case 'two-col': return 6
1787
- case 'three-col': return 4
1788
- // Narrow first block (filters / nav) beside a wide content column.
1789
- case 'sidebar': return i === 0 ? 4 : 8
1790
- // KPI-ish blocks form a 4-across strip; everything else spans full.
1791
- case 'kpi-row': return KPI_ROW_KINDS.includes(b.config.kind) ? 3 : 12
1792
- }
1793
- }
1794
- return { ...s, blocks: s.blocks.map((b, i) => ({ ...b, colSpan: span(i, b) })) }
1795
- })
1796
- }
1797
-
1798
- /** Persist a new dock workspace (user rearranged panes in the designer). */
1799
- export function setScreenDock(project: StudioProject, screenId: string, dock: DockManagerState): StudioProject {
1800
- return mapScreen(project, screenId, (s) => ({ ...s, dock }))
1801
- }
1802
-
1803
- // --- screen state variables --------------------------------------------------
1804
-
1805
- /** Add a state variable (unique name; `state_N` if the name is taken / empty). */
1806
- export function addStateVar(project: StudioProject, screenId: string, v: Partial<StateVar> = {}): StudioProject {
1807
- return mapScreen(project, screenId, (s) => {
1808
- const taken = new Set((s.state ?? []).map((x) => x.name))
1809
- let name = identSafe(v.name || 'value')
1810
- if (taken.has(name)) { let i = 2; while (taken.has(`${name}${i}`)) i++; name = `${name}${i}` }
1811
- const next: StateVar = { name, type: v.type ?? 'string', initial: v.initial }
1812
- return { ...s, state: [...(s.state ?? []), next] }
1813
- })
1814
- }
1815
- /** Patch a state variable by (old) name. Renames keep the identifier valid + unique. */
1816
- export function updateStateVar(project: StudioProject, screenId: string, name: string, patch: Partial<StateVar>): StudioProject {
1817
- return mapScreen(project, screenId, (s) => {
1818
- const cur = s.state ?? []
1819
- const taken = new Set(cur.filter((x) => x.name !== name).map((x) => x.name))
1820
- return {
1821
- ...s,
1822
- state: cur.map((x) => {
1823
- if (x.name !== name) return x
1824
- let nm = patch.name !== undefined ? identSafe(patch.name) : x.name
1825
- if (nm !== x.name && taken.has(nm)) { let i = 2; while (taken.has(`${nm}${i}`)) i++; nm = `${nm}${i}` }
1826
- return { ...x, ...patch, name: nm }
1827
- }),
1828
- }
1829
- })
1830
- }
1831
- /** Remove a state variable by name. */
1832
- export function removeStateVar(project: StudioProject, screenId: string, name: string): StudioProject {
1833
- return mapScreen(project, screenId, (s) => ({ ...s, state: (s.state ?? []).filter((x) => x.name !== name) }))
1834
- }
1835
-
1836
- /** Rebuild the pane arrangement from scratch (smart auto-layout, honoring the
1837
- * current split orientation). Used when a setting invalidates the layout - e.g.
1838
- * the user flips split orientation - or as a "reset arrangement" action. */
1839
- export function reseedScreenDock(project: StudioProject, screenId: string): StudioProject {
1840
- return mapScreen(project, screenId, (s) => ({ ...s, dock: buildDockLayout(s) }))
1841
- }
1842
-
1843
- /** The current title (tab text) of a block's dock pane, if the screen is docked. */
1844
- export function dockPaneTitleOf(screen: Screen, paneId: string): string | undefined {
1845
- if (!screen.dock) return undefined
1846
- let found: string | undefined
1847
- const walk = (n: DockNode) => { if (n.type === 'tabs') { const p = n.panes.find((x: DockPane) => x.id === paneId); if (p) found = p.title } else for (const c of n.children) walk(c) }
1848
- if (screen.dock.main) walk(screen.dock.main)
1849
- for (const w of screen.dock.floating) { const p = w.leaf.panes.find((x: DockPane) => x.id === paneId); if (p) found = p.title }
1850
- for (const e of screen.dock.autoHide) { const p = e.leaf.panes.find((x: DockPane) => x.id === paneId); if (p) found = p.title }
1851
- return found
1852
- }
1853
-
1854
- /** Rename a block's dock pane (the tab text) anywhere in the workspace. */
1855
- export function setDockPaneTitle(project: StudioProject, screenId: string, paneId: string, title: string): StudioProject {
1856
- return mapScreen(project, screenId, (s) => {
1857
- if (!s.dock) return s
1858
- const relabelPanes = (panes: DockPane[]): DockPane[] => panes.map((p) => (p.id === paneId ? { ...p, title } : p))
1859
- const relabel = (n: DockNode): DockNode => (n.type === 'tabs' ? { ...n, panes: relabelPanes(n.panes) } : { ...n, children: n.children.map(relabel) })
1860
- const main = s.dock.main ? relabel(s.dock.main) : null
1861
- const floating = s.dock.floating.map((w: DockManagerState['floating'][number]) => ({ ...w, leaf: { ...w.leaf, panes: relabelPanes(w.leaf.panes) } }))
1862
- const autoHide = s.dock.autoHide.map((e: DockManagerState['autoHide'][number]) => ({ ...e, leaf: { ...e.leaf, panes: relabelPanes(e.leaf.panes) } }))
1863
- return { ...s, dock: { ...s.dock, main, floating, autoHide } }
1864
- })
1865
- }
1866
-
1867
- /** A freestanding screen: no bound entity, so no blocks/data binding - just a
1868
- * title and (once actions are added) a toolbar. For a blank page the developer
1869
- * builds on, or one that's purely a home for custom actions ("Run report"). */
1870
- export function addFreestandingScreen(project: StudioProject, opts: { title: string; route?: string }): StudioProject {
1871
- const id = 'screen'
1872
- const route = opts.route ?? id
1873
- return appendScreen(project, { id, title: opts.title, route, blocks: [] }, 'screen')
1874
- }
1875
-
1876
- /** Every custom action id already used project-wide (screen toolbars + row
1877
- * actions) - action ids become `/api/actions/<id>` routes, so they must be
1878
- * unique across the whole project, not just within one screen. */
1879
- function takenActionIds(project: StudioProject): Set<string> {
1880
- const taken = new Set<string>()
1881
- for (const s of project.screens) {
1882
- for (const a of s.actions ?? []) taken.add(a.id)
1883
- for (const b of flattenBlocks(s.blocks)) {
1884
- if (b.config.kind !== 'grid') continue
1885
- for (const a of b.config.rowActions ?? []) if (a.kind === 'custom' && a.id) taken.add(a.id)
1886
- }
1887
- }
1888
- return taken
1889
- }
1890
-
1891
- /** Add a toolbar-level custom action to a screen (works on a freestanding screen
1892
- * too). Generates a stable id from `taken` project-wide ids; the generated app
1893
- * gets a wired-up button + a stub `/api/actions/<id>` route to fill in. */
1894
- export function addScreenAction(project: StudioProject, screenId: string, action: { label: string; icon?: string; confirm?: string }): StudioProject {
1895
- const id = uid('action-', takenActionIds(project))
1896
- return mapScreen(project, screenId, (s) => ({ ...s, actions: [...(s.actions ?? []), { id, ...action }] }))
1897
- }
1898
-
1899
- export function removeScreenAction(project: StudioProject, screenId: string, actionId: string): StudioProject {
1900
- return mapScreen(project, screenId, (s) => ({ ...s, actions: (s.actions ?? []).filter((a) => a.id !== actionId) }))
1901
- }
1902
-
1903
- /** Opt a screen into a user-owned `handlers.ts` companion (design + your own code).
1904
- * The generator scaffolds the stub once and never rewrites it. */
1905
- export function enableScreenCode(project: StudioProject, screenId: string): StudioProject {
1906
- return mapScreen(project, screenId, (s) => ({ ...s, code: true }))
1907
- }
1908
-
1909
- /** Drop the code companion binding (leaves any file the user already wrote on disk;
1910
- * the generator simply stops emitting/importing it). */
1911
- export function disableScreenCode(project: StudioProject, screenId: string): StudioProject {
1912
- return mapScreen(project, screenId, (s) => ({ ...s, code: false, renderGrid: undefined }))
1913
- }
1914
-
1915
- /** Toggle whether the page renders a Grid fed by `ctx.setRows` from `onLoad`.
1916
- * Implies `code: true` (a Grid needs the handler to fill it). */
1917
- export function setScreenRenderGrid(project: StudioProject, screenId: string, on: boolean): StudioProject {
1918
- return mapScreen(project, screenId, (s) => ({ ...s, code: on ? true : s.code, renderGrid: on || undefined }))
1919
- }
1920
-
1921
- /** Set the body of one event handler (e.g. `load`) - the code inside the generated
1922
- * function. This is what the designer's Code view edits per slot (the "single
1923
- * onLoad block"). Empty clears it back to the stub default. Implies `code: true`. */
1924
- export function setHandlerBody(project: StudioProject, screenId: string, handler: string, body: string): StudioProject {
1925
- return mapScreen(project, screenId, (s) => {
1926
- const bodies = { ...(s.handlerBodies ?? {}) }
1927
- if (body.trim()) bodies[handler] = body
1928
- else delete bodies[handler]
1929
- return { ...s, code: true, handlerBodies: Object.keys(bodies).length ? bodies : undefined }
1930
- })
1931
- }
1932
-
1933
- /** Set the full source the designer writes into the screen's `handlers.ts` companion.
1934
- * An advanced escape hatch that overrides the structured per-event bodies: when
1935
- * present it is emitted verbatim. Empty string clears it. Implies `code: true`. */
1936
- export function setScreenHandlersSource(project: StudioProject, screenId: string, source: string): StudioProject {
1937
- const trimmed = source.trim()
1938
- return mapScreen(project, screenId, (s) => ({ ...s, code: true, handlersSource: trimmed || undefined }))
1939
- }
1940
-
1941
- // --- visual methods (the Methods panel) ------------------------------------
1942
- /** Replace the steps of one method slot (`onLoad`, `onDestroy`, or `click:<blockId>`).
1943
- * An empty list clears the slot. Implies `code: true`. */
1944
- export function setHandlerSteps(project: StudioProject, screenId: string, slot: string, steps: ActionStep[]): StudioProject {
1945
- return mapScreen(project, screenId, (s) => {
1946
- const all = { ...(s.handlerSteps ?? {}) }
1947
- if (steps.length) all[slot] = steps
1948
- else delete all[slot]
1949
- return { ...s, code: true, handlerSteps: Object.keys(all).length ? all : undefined }
1950
- })
1951
- }
1952
- /** Append a step to a method slot. */
1953
- export function addHandlerStep(project: StudioProject, screenId: string, slot: string, step: ActionStep): StudioProject {
1954
- const s = project.screens.find((x) => x.id === screenId)
1955
- return setHandlerSteps(project, screenId, slot, [...(s?.handlerSteps?.[slot] ?? []), step])
1956
- }
1957
- /** Replace / remove a step at `index` (null removes it). */
1958
- export function updateHandlerStep(project: StudioProject, screenId: string, slot: string, index: number, step: ActionStep | null): StudioProject {
1959
- const s = project.screens.find((x) => x.id === screenId)
1960
- const steps = [...(s?.handlerSteps?.[slot] ?? [])]
1961
- if (index < 0 || index >= steps.length) return project
1962
- if (step === null) steps.splice(index, 1)
1963
- else steps[index] = step
1964
- return setHandlerSteps(project, screenId, slot, steps)
1965
- }
1966
- /** Move a step within its slot (dir -1 up, +1 down). */
1967
- export function moveHandlerStep(project: StudioProject, screenId: string, slot: string, index: number, dir: -1 | 1): StudioProject {
1968
- const s = project.screens.find((x) => x.id === screenId)
1969
- const steps = [...(s?.handlerSteps?.[slot] ?? [])]
1970
- const j = index + dir
1971
- if (index < 0 || index >= steps.length || j < 0 || j >= steps.length) return project
1972
- ;[steps[index], steps[j]] = [steps[j]!, steps[index]!]
1973
- return setHandlerSteps(project, screenId, slot, steps)
1974
- }
1975
- /** Drop the visual steps for a slot into the raw code editor (compile once), so a
1976
- * user can hand-edit from there. Moves `handlerSteps[slot]` -> `handlerBodies[slot]`. */
1977
- export function stepsToCode(project: StudioProject, screenId: string, slot: string): StudioProject {
1978
- const s = project.screens.find((x) => x.id === screenId)
1979
- const steps = s?.handlerSteps?.[slot]
1980
- if (!steps?.length) return project
1981
- const withBody = setHandlerBody(project, screenId, slot, compileHandlerSteps(steps))
1982
- return setHandlerSteps(withBody, screenId, slot, [])
1983
- }
1984
-
1985
- /** Deep-clone `block` with fresh ids (recursing into Tabs children). */
1986
- function freshBlockIds(blocks: ReadonlyArray<Block>, taken: Set<string>): Block[] {
1987
- return blocks.map((b) => {
1988
- const config = JSON.parse(JSON.stringify(b.config)) as BlockConfig
1989
- const id = uid(`${config.kind}-`, taken)
1990
- taken.add(id)
1991
- if (config.kind === 'tabs') config.tabs = config.tabs.map((t) => ({ ...t, blocks: freshBlockIds(t.blocks, taken) }))
1992
- if (config.kind === 'accordion') config.sections = config.sections.map((s) => ({ ...s, blocks: freshBlockIds(s.blocks, taken) }))
1993
- return { ...b, id, config }
1994
- })
1995
- }
1996
-
1997
- /** Insert a block (deep-cloned, fresh id) into a screen - the paste target. Appends by default. */
1998
- export function insertBlock(project: StudioProject, screenId: string, block: Block, index?: number): StudioProject {
1999
- return mapScreen(project, screenId, (s) => {
2000
- const taken = new Set(flattenBlocks(s.blocks).map((b) => b.id))
2001
- const [clone] = freshBlockIds([block], taken)
2002
- const blocks = [...s.blocks]
2003
- blocks.splice(index ?? blocks.length, 0, clone!)
2004
- return { ...s, blocks }
2005
- })
2006
- }
2007
-
2008
- /** Duplicate a whole screen (fresh id / route / title + fresh block ids), inserted after it. */
2009
- export function duplicateScreen(project: StudioProject, screenId: string): StudioProject {
2010
- const idx = project.screens.findIndex((s) => s.id === screenId)
2011
- if (idx < 0) return project
2012
- const src = project.screens[idx]!
2013
- const ids = new Set(project.screens.map((s) => s.id))
2014
- const routes = new Set(project.screens.map((s) => s.route))
2015
- const id = uid(`${src.entity}-`, ids)
2016
- const route = uid(`${src.route}-`, routes)
2017
- // Seed the taken-ids set with the source's block ids so the clone's ids don't collide.
2018
- const clone: Screen = { ...src, id, route, title: `${src.title} copy`, blocks: freshBlockIds(src.blocks, new Set(flattenBlocks(src.blocks).map((b) => b.id))) }
2019
- const screens = [...project.screens]
2020
- screens.splice(idx + 1, 0, clone)
2021
- return { ...project, screens }
2022
- }
2023
-
2024
- /** Move a screen to an explicit index (drag-reorder the tab strip). Clamps to range. */
2025
- export function reorderScreen(project: StudioProject, screenId: string, toIndex: number): StudioProject {
2026
- const from = project.screens.findIndex((s) => s.id === screenId)
2027
- if (from < 0) return project
2028
- const screens = [...project.screens]
2029
- const [moved] = screens.splice(from, 1)
2030
- screens.splice(Math.max(0, Math.min(toIndex, screens.length)), 0, moved!)
2031
- return { ...project, screens }
2032
- }
2033
-
2034
- export function setDataSource(project: StudioProject, dataSource: DataSourceKind): StudioProject {
2035
- return { ...project, dataSource }
2036
- }
2037
-
2038
- /** Set the deploy target (SvelteKit adapter + provider config the bundle emits). */
2039
- export function setDeployTarget(project: StudioProject, deploy: DeployTarget): StudioProject {
2040
- if (deploy === 'auto') { const { deploy: _drop, ...rest } = project; return rest }
2041
- return { ...project, deploy }
2042
- }
2043
-
2044
- /** Enable / disable the authentication starter (login + session + hooks). */
2045
- export function setAuth(project: StudioProject, patch: Partial<AuthConfig> & { enabled: boolean }): StudioProject {
2046
- if (!patch.enabled) { const { auth: _drop, ...rest } = project; return rest }
2047
- const prev = project.auth
2048
- const oauth = patch.oauth ?? prev?.oauth
2049
- const provider = patch.provider ?? prev?.provider
2050
- return {
2051
- ...project,
2052
- auth: {
2053
- enabled: true,
2054
- ...(provider && provider !== 'builtin' ? { provider } : {}),
2055
- protect: patch.protect ?? prev?.protect ?? true,
2056
- register: patch.register ?? prev?.register ?? false,
2057
- userAdmin: patch.userAdmin ?? prev?.userAdmin ?? false,
2058
- ...(oauth && oauth.length ? { oauth } : {}),
2059
- ...((patch.twoFactor ?? prev?.twoFactor) ? { twoFactor: true } : {}),
2060
- ...((patch.email ?? prev?.email) ? { email: true } : {}),
2061
- },
2062
- }
2063
- }
2064
-
2065
- /** Enable / disable the typed Drizzle data layer (schema + repos + migrations). */
2066
- export function setDataLayer(project: StudioProject, enabled: boolean): StudioProject {
2067
- if (!enabled) { const { dataLayer: _drop, ...rest } = project; return rest }
2068
- return { ...project, dataLayer: 'drizzle' }
2069
- }
2070
-
2071
- /** A demo user seed for the auth starter: one user per RBAC role (so you can sign in
2072
- * and see each role's access), or a single admin when RBAC is off. Passwords are
2073
- * demo seeds (like sample rows) - the generated code flags them for replacement. */
2074
- export type SeedUser = { email: string; name: string; role: string; password: string }
2075
- export function seedUsers(project: StudioProject): SeedUser[] {
2076
- const roles = project.access?.enabled ? project.access.roles.map((r) => r.role) : []
2077
- if (!roles.length) return [{ email: 'admin@example.com', name: 'Admin', password: 'admin1234', role: 'admin' }]
2078
- return roles.map((role) => ({
2079
- email: `${role.replace(/[^a-z0-9]+/gi, '')}@example.com`.toLowerCase(),
2080
- name: role.charAt(0).toUpperCase() + role.slice(1),
2081
- role,
2082
- password: `${role.replace(/[^a-z0-9]+/gi, '').toLowerCase()}1234`,
2083
- }))
2084
- }
2085
-
2086
- /** A skeleton binding for a kind, seeded with the entity's name as its table/path. */
2087
- export function defaultEntitySource(kind: DataSourceKind, entityName: string): EntityDataSource {
2088
- switch (kind) {
2089
- case 'rest': return { kind: 'rest', baseUrl: '', path: entityName, method: 'GET', params: [] }
2090
- case 'sql': return { kind: 'sql', table: entityName }
2091
- case 'supabase': return { kind: 'supabase', table: entityName }
2092
- case 'pglite': return { kind: 'pglite', table: entityName }
2093
- default: return { kind: 'memory' }
2094
- }
2095
- }
2096
-
2097
- /** Bind one entity to a data source (REST / SQL / Supabase / in-memory). */
2098
- export function setEntityDataSource(project: StudioProject, entityName: string, source: EntityDataSource): StudioProject {
2099
- return { ...project, dataSources: { ...project.dataSources, [entityName]: source } }
2100
- }
2101
-
2102
- /** The resolved source for an entity: its explicit per-entity binding, else a skeleton
2103
- * for the project's default source kind (so the rail "Default source" actually applies
2104
- * to unbound entities, and this agrees with `ssrScreenShape`'s fallback). */
2105
- export function entityDataSource(project: StudioProject, entityName: string): EntityDataSource {
2106
- return project.dataSources?.[entityName] ?? defaultEntitySource(project.dataSource ?? 'memory', entityName)
2107
- }
2108
-
2109
- export function setTheme(project: StudioProject, theme: ProjectTheme): StudioProject {
2110
- return { ...project, theme: { ...project.theme, ...theme } }
2111
- }
2112
-
2113
- /** Apply a Studio theme preset: sets the preset id and clears any manual accent
2114
- * override so the preset's own accent takes effect (the user can re-tune it
2115
- * afterwards with the color picker). Pass `accent` to pin one explicitly. */
2116
- export function setThemePreset(project: StudioProject, preset: string, accent?: string): StudioProject {
2117
- const theme = { ...project.theme, preset }
2118
- if (accent) theme.accent = accent
2119
- else delete theme.accent
2120
- return { ...project, theme }
2121
- }
2122
-
2123
- /** Configure the generated app shell (layout style, brand, footer, nav position). */
2124
- export function setShell(project: StudioProject, shell: ShellConfig): StudioProject {
2125
- return { ...project, theme: { ...project.theme, shell: { ...project.theme?.shell, ...shell } } }
2126
- }
2127
-
2128
- // --- screen templates ------------------------------------------------------
2129
-
2130
- export type ScreenTemplate = 'crud' | 'dashboard' | 'master-detail' | 'empty'
2131
-
2132
- /** Build a screen for an entity from a template (a preset arrangement of blocks). */
2133
- export function screenFromTemplate(
2134
- entity: EntitySchema,
2135
- template: ScreenTemplate,
2136
- opts: { child?: EntitySchema; foreignKey?: string } = {},
2137
- ): Screen {
2138
- const taken = new Set<string>()
2139
- const make = (kind: BlockKind, span?: 1 | 2 | 3, config?: BlockConfig): Block => {
2140
- const b = makeBlock(kind, entity, taken)
2141
- taken.add(b.id)
2142
- if (span) b.span = span
2143
- if (config) b.config = config
2144
- return b
2145
- }
2146
- const measure = pickMeasure(entity)
2147
- const label = entity.label ?? entity.name
2148
-
2149
- let blocks: Block[]
2150
- if (template === 'empty') {
2151
- blocks = []
2152
- } else if (template === 'dashboard') {
2153
- const kpis: Block[] = [make('kpi', 1, { kind: 'kpi', label: `Total ${label}`, reduce: 'count' })]
2154
- if (measure) kpis.push(make('kpi', 1, { kind: 'kpi', label: `Total ${measure}`, measure, reduce: 'sum' }))
2155
- blocks = [...kpis, make('chart', 2), make('grid', 3)]
2156
- } else if (template === 'master-detail') {
2157
- const md = make('master-detail', 3, {
2158
- kind: 'master-detail',
2159
- childEntity: opts.child?.name ?? '',
2160
- foreignKey: opts.foreignKey ?? opts.child?.fields.find((f) => f.type === 'relation' && f.relation?.entity === entity.name)?.field ?? '',
2161
- })
2162
- blocks = [make('grid', 3), md]
2163
- } else {
2164
- blocks = [make('grid', 3)] // crud (the grid edits via a popup form)
2165
- }
2166
- return { id: entity.name, entity: entity.name, title: label, route: entity.name, blocks }
2167
- }
2168
-
2169
- /** Add a screen built from a template. `child` (for master-detail) is an entity name. */
2170
- export function addScreenFromTemplate(
2171
- project: StudioProject,
2172
- entityName: string,
2173
- template: ScreenTemplate,
2174
- opts: { child?: string; foreignKey?: string } = {},
2175
- ): StudioProject {
2176
- const entity = entityOf(project, entityName)
2177
- if (!entity) return project
2178
- const base = screenFromTemplate(entity, template, {
2179
- child: opts.child ? entityOf(project, opts.child) : undefined,
2180
- foreignKey: opts.foreignKey,
2181
- })
2182
- return appendScreen(project, base, entityName)
2183
- }
2184
-
2185
- // --- persistence: round-trippable studio.config -----------------------------
2186
-
2187
- /** `name`, or the first free `${name}_${n}` if taken. Records the result. */
2188
- function makeUnique(name: string, taken: Set<string>): string {
2189
- if (!taken.has(name)) { taken.add(name); return name }
2190
- let i = 2
2191
- while (taken.has(`${name}_${i}`)) i++
2192
- const u = `${name}_${i}`
2193
- taken.add(u)
2194
- return u
2195
- }
2196
-
2197
- /**
2198
- * Enforce the structural invariants the designer + codegen rely on: unique
2199
- * entity names, unique field names within an entity, unique screen ids + routes,
2200
- * and unique block ids within a screen. In-app ops already keep these, but a
2201
- * hand-edited / externally-produced `studio.config.json` may not - and a
2202
- * duplicate key would crash a keyed `{#each}` list at render time. Idempotent:
2203
- * a project that already satisfies the invariants is returned value-equal.
2204
- */
2205
- export function sanitizeProject(project: StudioProject): StudioProject {
2206
- const entityNames = new Set<string>()
2207
- const entities: EntitySchema[] = []
2208
- for (const e of project.entities) {
2209
- if (entityNames.has(e.name)) continue // drop a duplicate-named entity; screens still resolve to the survivor
2210
- entityNames.add(e.name)
2211
- const fieldNames = new Set<string>()
2212
- entities.push({ ...e, fields: e.fields.map((f) => ({ ...f, field: makeUnique(f.field, fieldNames) })) })
2213
- }
2214
- const ids = new Set<string>()
2215
- const routes = new Set<string>()
2216
- const screens = project.screens.map((s) => {
2217
- const blockIds = new Set<string>()
2218
- return {
2219
- ...s,
2220
- id: makeUnique(s.id, ids),
2221
- route: makeUnique(s.route, routes),
2222
- blocks: s.blocks.map((b) => ({ ...b, id: makeUnique(b.id, blockIds) })),
2223
- }
2224
- })
2225
- const result: StudioProject = { ...project, entities, screens }
2226
- // Drop per-entity source bindings for entities that no longer exist.
2227
- if (project.dataSources) {
2228
- const pruned = Object.fromEntries(Object.entries(project.dataSources).filter(([n]) => entityNames.has(n)))
2229
- if (Object.keys(pruned).length) result.dataSources = pruned
2230
- else delete result.dataSources
2231
- }
2232
- return result
2233
- }
2234
-
2235
- /** Serialize a project to a `studio.config.json` string (the persisted design). */
2236
- export function serializeProject(project: StudioProject): string {
2237
- return JSON.stringify(project, null, 2)
2238
- }
2239
-
2240
- /**
2241
- * Parse a `studio.config.json` string back into a `StudioProject`, validating
2242
- * the shape. Note: only the data model round-trips - any runtime functions on an
2243
- * entity (`computed` / `validate` / `hooks`) are not part of the config.
2244
- */
2245
- export function parseProject(json: string): StudioProject {
2246
- let raw: unknown
2247
- try {
2248
- raw = JSON.parse(json)
2249
- } catch {
2250
- throw new Error('parseProject: not valid JSON')
2251
- }
2252
- if (!raw || typeof raw !== 'object') throw new Error('parseProject: expected a project object')
2253
- const p = raw as Partial<StudioProject>
2254
- // An empty `entities` array is a valid work-in-progress (a "start from zero"
2255
- // project). Codegen is gated separately by validateProject / isProjectValid.
2256
- if (!Array.isArray(p.entities)) throw new Error('parseProject: missing "entities"')
2257
- if (!Array.isArray(p.screens)) throw new Error('parseProject: missing "screens"')
2258
- return sanitizeProject({
2259
- title: typeof p.title === 'string' ? p.title : 'My Studio App',
2260
- entities: p.entities as EntitySchema[],
2261
- screens: p.screens as Screen[],
2262
- dataSource: (p.dataSource ?? 'memory') as DataSourceKind,
2263
- ...(p.dataSources && typeof p.dataSources === 'object' ? { dataSources: p.dataSources as Record<string, EntityDataSource> } : {}),
2264
- ...(p.supabase && typeof p.supabase === 'object' ? { supabase: p.supabase as { url?: string; key?: string } } : {}),
2265
- ...(p.theme && typeof p.theme === 'object' ? { theme: p.theme as ProjectTheme } : {}),
2266
- ...(p.access && typeof p.access === 'object' ? { access: p.access as AccessControl } : {}),
2267
- ...(p.auth && typeof p.auth === 'object' && (p.auth as AuthConfig).enabled ? { auth: p.auth as AuthConfig } : {}),
2268
- ...(p.dataLayer === 'drizzle' ? { dataLayer: 'drizzle' as const } : {}),
2269
- ...(typeof p.audit === 'boolean' ? { audit: p.audit } : {}),
2270
- ...(p.i18n && typeof p.i18n === 'object' ? { i18n: p.i18n as I18nConfig } : {}),
2271
- ...(typeof p.deploy === 'string' && p.deploy !== 'auto' ? { deploy: p.deploy as DeployTarget } : {}),
2272
- ...(p.triggers && typeof p.triggers === 'object' ? { triggers: p.triggers as Record<string, EntityTriggers> } : {}),
2273
- })
2274
- }
2275
-
2276
- /** Validate a project. Errors block codegen; warnings are advisory. */
2277
- export function validateProject(project: StudioProject): ProjectIssue[] {
2278
- const issues: ProjectIssue[] = []
2279
- if (project.entities.length === 0) issues.push({ level: 'error', message: 'Add at least one entity.' })
2280
- if (project.screens.length === 0) issues.push({ level: 'warning', message: 'No screens yet.' })
2281
-
2282
- // Form conditions read other fields by name. A name that resolves to nothing
2283
- // compares against undefined, so the field it guards would sit hidden or
2284
- // locked forever with no clue why - worth catching before it ships.
2285
- for (const entity of project.entities) {
2286
- const names = new Set(entity.fields.map((f) => f.field))
2287
- for (const field of entity.fields) {
2288
- if (!field.when) continue
2289
- for (const [flag, expr] of Object.entries(field.when)) {
2290
- if (!expr) continue
2291
- for (const ref of collectColumnRefs(expr as PredicateExpr)) {
2292
- if (!names.has(ref)) {
2293
- issues.push({
2294
- level: 'error',
2295
- message: `"${entity.name}.${field.field}" has a ${flag} condition on "${ref}", which is not a field on ${entity.name}.`,
2296
- })
2297
- }
2298
- }
2299
- }
2300
- }
2301
- }
2302
-
2303
- const routes = new Set<string>()
2304
- for (const s of project.screens) {
2305
- // `entity` is optional (a freestanding screen has none, by design) - only a
2306
- // *dangling* reference (set but unresolvable) is an error.
2307
- if (s.entity !== undefined && !entityOf(project, s.entity)) {
2308
- issues.push({ level: 'error', message: `Screen "${s.title}" points at a missing entity "${s.entity}".`, screen: s.id })
2309
- }
2310
- if (routes.has(s.route)) issues.push({ level: 'error', message: `Duplicate route "/${s.route}".`, screen: s.id })
2311
- routes.add(s.route)
2312
- if (s.entity !== undefined && s.blocks.length === 0) {
2313
- issues.push({ level: 'warning', message: `Screen "${s.title}" has no blocks.`, screen: s.id })
2314
- } else if (s.entity === undefined && !s.actions?.length && s.blocks.length === 0) {
2315
- issues.push({ level: 'warning', message: `Screen "${s.title}" is empty - add an action or some content.`, screen: s.id })
2316
- }
2317
- for (const b of flattenBlocks(s.blocks)) {
2318
- const at = (message: string, level: ProjectIssueLevel = 'warning'): ProjectIssue => ({ level, message, screen: s.id, block: b.id })
2319
- const c = b.config
2320
- if (c.kind === 'master-detail') {
2321
- if (!c.childEntity || !c.foreignKey) issues.push(at('Master/detail needs a child entity + foreign key.'))
2322
- else if (!entityOf(project, c.childEntity)) issues.push(at(`Master/detail points at a missing child entity "${c.childEntity}".`))
2323
- } else if (c.kind === 'tree') {
2324
- if (!c.labelField || !c.parentField) issues.push(at('Tree needs a label field and a self-referential parent field.'))
2325
- } else if (c.kind === 'lookup') {
2326
- if (!c.field) issues.push(at('Lookup needs a relation field.'))
2327
- } else if (c.kind === 'filter') {
2328
- if (!c.fields.length) issues.push(at('Filter panel has no facets - pick fields to filter on.'))
2329
- } else if (c.kind === 'chart') {
2330
- if (!c.dimension) issues.push(at('Chart has no group-by dimension.'))
2331
- } else if (c.kind === 'pivot') {
2332
- if (!c.rows.length && !c.cols.length) issues.push(at('Pivot has no row or column dimensions.'))
2333
- } else if (c.kind === 'tabs') {
2334
- if (c.tabs.every((t) => t.blocks.length === 0)) issues.push(at('Tabs container has no blocks in any tab.'))
2335
- } else if (c.kind === 'accordion') {
2336
- if (c.sections.every((s) => s.blocks.length === 0)) issues.push(at('Accordion container has no blocks in any section.'))
2337
- } else if (c.kind === 'component') {
2338
- if (!c.component) issues.push(at('Component block has no component selected.'))
2339
- }
2340
- }
2341
- }
2342
-
2343
- // Project-level deployment footguns (all advisory - the app still generates).
2344
- const sources = Object.values(project.dataSources ?? {})
2345
- const sqlSources = sources.filter((s): s is Extract<EntityDataSource, { kind: 'sql' }> => s.kind === 'sql')
2346
- if (sqlSources.length > 0 && project.dataLayer !== 'drizzle') {
2347
- issues.push({
2348
- level: 'warning',
2349
- message:
2350
- 'SQL entities have no migrations - their tables must already exist in the database. ' +
2351
- 'Enable the Drizzle data layer for typed migrations, or run the generated db/schema.sql against your database first.',
2352
- })
2353
- }
2354
- if (project.deploy === 'cloudflare' && sqlSources.some((s) => s.dialect === 'postgres' || s.dialect === 'supabase')) {
2355
- issues.push({
2356
- level: 'warning',
2357
- message:
2358
- 'Cloudflare Workers cannot run the socket "pg" Postgres driver. Swap the generated route to an HTTP driver ' +
2359
- '(e.g. Neon serverless) or choose another deploy target.',
2360
- })
2361
- }
2362
- for (const [name, src] of Object.entries(project.dataSources ?? {})) {
2363
- if (src.kind === 'supabase' && !(src.url ?? project.supabase?.url) && !(src.key ?? project.supabase?.key)) {
2364
- issues.push({
2365
- level: 'warning',
2366
- message: `Supabase entity "${name}" has no URL/key - set the shared project connection (or this entity's own), or the generated app just reads PUBLIC_SUPABASE_URL / PUBLIC_SUPABASE_ANON_KEY from .env.`,
2367
- })
2368
- }
2369
- }
2370
- // Supabase Auth needs a Supabase client to authenticate against: either the shared
2371
- // project connection or at least one Supabase-bound entity (both emit connections.ts).
2372
- if (project.auth?.enabled && project.auth.provider === 'supabase') {
2373
- const hasSupabase = !!project.supabase?.url || sources.some((s) => s.kind === 'supabase')
2374
- if (!hasSupabase) {
2375
- issues.push({
2376
- level: 'warning',
2377
- message: 'Supabase Auth needs a Supabase connection. Set the shared project URL / anon key (in a data-source builder), or bind an entity to Supabase.',
2378
- })
2379
- }
2380
- // Supabase Auth is a client-side gate; it does not populate the server-side role
2381
- // the RBAC route guard reads. Server enforcement must come from RLS instead.
2382
- if (project.access?.enabled) {
2383
- issues.push({
2384
- level: 'warning',
2385
- message: 'Supabase Auth signs in on the client, so it does not populate the server-side role that RBAC route guards check. Enforce per-user access with Row Level Security policies on your Supabase tables.',
2386
- })
2387
- }
2388
- }
2389
- return issues
2390
- }
2391
-
2392
- export function isProjectValid(project: StudioProject): boolean {
2393
- return !validateProject(project).some((i) => i.level === 'error')
2394
- }