@svgrid/enterprise 2.7.0 → 3.0.1

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 (238) hide show
  1. package/README.md +95 -95
  2. package/dist/ExpressionEditorHarness.svelte.d.ts +2 -2
  3. package/dist/SvAdvancedFilter.svelte.d.ts +2 -2
  4. package/dist/SvAlertRuleEditor.svelte.d.ts +2 -2
  5. package/dist/SvAlertsManager.svelte.d.ts +3 -3
  6. package/dist/SvAlertsPanel.svelte.d.ts +1 -1
  7. package/dist/SvAuthGate.svelte +115 -115
  8. package/dist/SvAuthGate.svelte.d.ts +1 -1
  9. package/dist/SvBoard.svelte.d.ts +1 -1
  10. package/dist/SvExportMenu.svelte +553 -553
  11. package/dist/SvExportMenu.svelte.d.ts +2 -2
  12. package/dist/SvExpressionEditor.svelte.d.ts +2 -2
  13. package/dist/SvFileInput.svelte +113 -113
  14. package/dist/SvGridAlerts.svelte.d.ts +2 -2
  15. package/dist/SvGridBoard.svelte +2358 -2358
  16. package/dist/SvGridBulkEditDrawer.svelte.d.ts +1 -1
  17. package/dist/SvGridEditPanel.svelte +1157 -1157
  18. package/dist/SvGridEditPanel.svelte.d.ts +3 -3
  19. package/dist/SvGridMasterDetail.svelte +116 -116
  20. package/dist/SvGridMasterDetail.svelte.d.ts +1 -1
  21. package/dist/SvGridScheduler.svelte +5358 -5358
  22. package/dist/SvImportDialog.svelte +1020 -1020
  23. package/dist/SvImportDialog.svelte.d.ts +2 -2
  24. package/dist/SvLookupInput.svelte +180 -180
  25. package/dist/SvLookupInput.svelte.d.ts +1 -1
  26. package/dist/SvPivotDesigner.svelte +1990 -1990
  27. package/dist/SvPivotDesigner.svelte.d.ts +2 -2
  28. package/dist/SvRecordDetail.svelte.d.ts +1 -1
  29. package/dist/SvSchedule.svelte.d.ts +1 -1
  30. package/dist/SvSchemaChart.svelte +234 -234
  31. package/dist/SvSchemaChart.svelte.d.ts +2 -2
  32. package/dist/SvSchemaDashboard.svelte +130 -130
  33. package/dist/SvSchemaDashboard.svelte.d.ts +3 -3
  34. package/dist/advanced-filter/expr-columns-from-grid.d.ts +1 -1
  35. package/dist/advanced-filter-enable.js +3 -3
  36. package/dist/alerts/alert-engine-attach.d.ts +2 -2
  37. package/dist/alerts/alert-engine-attach.js +3 -3
  38. package/dist/alerts/alert-engine.d.ts +1 -1
  39. package/dist/alerts/alert-engine.js +2 -2
  40. package/dist/alerts/alert-formats.d.ts +1 -1
  41. package/dist/alerts/alert-formats.js +1 -1
  42. package/dist/alerts/alert-observer.d.ts +3 -3
  43. package/dist/alerts/alert-observer.js +1 -1
  44. package/dist/alerts/alert-storage.d.ts +1 -1
  45. package/dist/alerts/alert-store.svelte.d.ts +1 -1
  46. package/dist/alerts/alert-types.d.ts +2 -2
  47. package/dist/alerts.js +2 -2
  48. package/dist/board.js +2 -2
  49. package/dist/cdn/svgrid-enterprise.svelte-external.js +1162 -1145
  50. package/dist/designer/assets/GridMenus-B1JtIN21.js +8 -0
  51. package/dist/designer/assets/SvDateRangeInput-C0iCCZ8o.css +1 -0
  52. package/dist/designer/assets/{SvDateRangeInput-CVn-yqrQ.js → SvDateRangeInput-CjO7nn_x.js} +1 -1
  53. package/dist/designer/assets/SvDateTimePicker-ByBD_ov6.js +1 -0
  54. package/dist/designer/assets/SvDateTimePicker-DPv09DIT.css +1 -0
  55. package/dist/designer/assets/SvField-YDLipR6O.css +1 -0
  56. package/dist/designer/assets/{SvField-BsqiA5me.js → SvField-cj1PFNgZ.js} +1 -1
  57. package/dist/designer/assets/{preload-helper-CG0n22O7.js → SvGrid.helpers-BgT-gnCo.js} +1 -1
  58. package/dist/designer/assets/SvGridCellEditor-0W1L2hJ1.js +2 -0
  59. package/dist/designer/assets/SvGridChart-DBOR4xcz.js +2 -0
  60. package/dist/designer/assets/SvGridChart-fkMWeh1J.css +1 -0
  61. package/dist/designer/assets/SvGridChartPanel-symrhNnR.js +11 -0
  62. package/dist/designer/assets/SvGridChartView-BmRxq6mU.js +1 -0
  63. package/dist/designer/assets/SvGridChartView-DUc-1RUf.css +1 -0
  64. package/dist/designer/assets/SvGridDropdown-Bb3HH2Aj.js +1 -0
  65. package/dist/designer/assets/SvGridDropdown-CcidB3rm.css +1 -0
  66. package/dist/designer/assets/SvListBox-1oUF6Iav.js +110 -0
  67. package/dist/designer/assets/SvListBox-BXfyqy4a.css +1 -0
  68. package/dist/designer/assets/cell-formatting-wr5wm7US.js +1 -0
  69. package/dist/designer/assets/chart-DXFganU8.js +1 -0
  70. package/dist/designer/assets/date-format-DGagAqRc.css +1 -0
  71. package/dist/designer/assets/{date-format-4DJvNtG2.js → date-format-onlYDsrY.js} +1 -1
  72. package/dist/designer/assets/export-format-D-eP4b2l.js +6 -0
  73. package/dist/designer/assets/{index-BvaLmQSd.js → index-C_a9Cj8Y.js} +2984 -141
  74. package/dist/designer/assets/index-k2stW6L8.css +1 -0
  75. package/dist/designer/assets/preload-helper-HclGiUj8.js +1 -0
  76. package/dist/designer/assets/{row-drag-touch-IuJPEt0z.js → row-drag-touch-BdknxBNL.js} +1 -1
  77. package/dist/designer/assets/src-D9p27OGC.js +58 -0
  78. package/dist/designer/index.html +22 -22
  79. package/dist/export-conditional.d.ts +1 -1
  80. package/dist/export-print.js +19 -19
  81. package/dist/export-xls.d.ts +2 -2
  82. package/dist/export.d.ts +2 -2
  83. package/dist/export.js +7 -7
  84. package/dist/expressions/builder-tree.d.ts +2 -2
  85. package/dist/expressions/builder-tree.js +1 -1
  86. package/dist/expressions/compile.d.ts +1 -1
  87. package/dist/import.js +1 -1
  88. package/dist/index.d.ts +53 -53
  89. package/dist/index.js +50 -50
  90. package/dist/install.d.ts +4 -4
  91. package/dist/install.js +11 -11
  92. package/dist/license.d.ts +3 -2
  93. package/dist/license.js +9 -4
  94. package/dist/node/studio.js +124 -115
  95. package/dist/pivot-chart.d.ts +1 -1
  96. package/dist/pivot-designer.d.ts +1 -1
  97. package/dist/pivot-enable.js +3 -3
  98. package/dist/print.d.ts +1 -1
  99. package/dist/print.js +4 -4
  100. package/dist/scheduler-config.d.ts +5 -5
  101. package/dist/scheduler-freebusy.d.ts +1 -1
  102. package/dist/scheduler-freebusy.js +1 -1
  103. package/dist/scheduler.js +2 -2
  104. package/dist/schema-designer.d.ts +1 -1
  105. package/dist/schema-designer.js +2 -2
  106. package/dist/selection-bar.js +2 -2
  107. package/dist/sources/aggregate.d.ts +1 -1
  108. package/dist/sources/auth-supabase.js +1 -1
  109. package/dist/sources/dashboard.d.ts +2 -2
  110. package/dist/sources/dashboard.js +1 -1
  111. package/dist/sources/index.d.ts +13 -13
  112. package/dist/sources/index.js +13 -13
  113. package/dist/sources/realtime-supabase.js +1 -1
  114. package/dist/sources/relation-lookup.d.ts +1 -1
  115. package/dist/sources/relation-lookup.js +1 -1
  116. package/dist/sources/rest-adapters.d.ts +1 -1
  117. package/dist/sources/rest-adapters.js +1 -1
  118. package/dist/sources/rest.d.ts +2 -2
  119. package/dist/sources/rest.js +3 -3
  120. package/dist/sources/supabase.d.ts +2 -2
  121. package/dist/sources/supabase.js +3 -3
  122. package/dist/sources/with-entity-rules.d.ts +1 -1
  123. package/dist/sources/with-entity-rules.js +1 -1
  124. package/dist/sources/with-relation-labels.d.ts +1 -1
  125. package/dist/studio/emit-project.js +2161 -2127
  126. package/dist/studio/emit-schema.js +369 -369
  127. package/dist/studio/index.d.ts +1 -1
  128. package/dist/studio/index.js +1 -1
  129. package/dist/studio/introspect-db.js +45 -45
  130. package/dist/studio/project.d.ts +10 -0
  131. package/dist/studio/samples/crm.js +6 -6
  132. package/dist/studio/samples/fleet.js +7 -7
  133. package/dist/studio/samples/insurance.js +9 -9
  134. package/dist/studio/samples/inventory.js +12 -12
  135. package/dist/studio/samples/projects.js +5 -5
  136. package/dist/studio/samples/support.js +8 -8
  137. package/dist/studio/scaffold-app.js +104 -104
  138. package/dist/studio/scaffold.js +151 -151
  139. package/dist/studio/ui-components.d.ts +7 -0
  140. package/dist/studio/ui-components.js +35 -0
  141. package/dist/sveltekit/in-memory.d.ts +3 -3
  142. package/dist/sveltekit/in-memory.js +4 -4
  143. package/dist/sveltekit/index.d.ts +6 -6
  144. package/dist/sveltekit/index.js +5 -5
  145. package/dist/sveltekit/query-plan.d.ts +1 -1
  146. package/dist/sveltekit/sql-source.d.ts +4 -4
  147. package/dist/sveltekit/sql-source.js +4 -4
  148. package/dist/sveltekit/sql.d.ts +1 -1
  149. package/dist/sveltekit/transport.d.ts +2 -2
  150. package/dist/sveltekit/transport.js +1 -1
  151. package/dist/upgrade-prompt.js +25 -25
  152. package/dist/version.d.ts +1 -1
  153. package/dist/version.js +1 -1
  154. package/package.json +6 -3
  155. package/dist/designer/assets/GridMenus-Cgilexpk.js +0 -8
  156. package/dist/designer/assets/SvDateRangeInput-BV5OXYQ2.css +0 -1
  157. package/dist/designer/assets/SvDateTimePicker-BdioTrJQ.js +0 -1
  158. package/dist/designer/assets/SvDateTimePicker-CY8TK4BT.css +0 -1
  159. package/dist/designer/assets/SvField-BF-9TJSd.css +0 -1
  160. package/dist/designer/assets/SvGridCellEditor-dLY7_kk9.js +0 -2
  161. package/dist/designer/assets/SvGridChart-BEDNTdc5.js +0 -2
  162. package/dist/designer/assets/SvGridChart-Vu4Nvjiv.css +0 -1
  163. package/dist/designer/assets/SvGridChartPanel-x3lzSxuf.js +0 -10
  164. package/dist/designer/assets/SvGridChartView-CAKhPELi.js +0 -1
  165. package/dist/designer/assets/SvGridChartView-DX9HfkBR.css +0 -1
  166. package/dist/designer/assets/SvGridDropdown-CAYG7Y-O.js +0 -1
  167. package/dist/designer/assets/SvGridDropdown-Crwb2P3Q.css +0 -1
  168. package/dist/designer/assets/SvListBox-CVYQcLX9.js +0 -1
  169. package/dist/designer/assets/SvListBox-DgN8kvjt.css +0 -1
  170. package/dist/designer/assets/chart-DHEgt4lN.js +0 -1
  171. package/dist/designer/assets/date-format-Dzy4mTGh.css +0 -1
  172. package/dist/designer/assets/export-format-DwjuTfrQ.js +0 -6
  173. package/dist/designer/assets/index-Cp7fDJ3b.css +0 -1
  174. package/dist/designer/assets/js-scroller.svelte-Bhxqb3-c.js +0 -110
  175. package/dist/designer/assets/src-7qOYsBug.js +0 -2896
  176. package/dist/designer/assets/src-B7DNcoQK.css +0 -1
  177. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/_svelte_metadata.json +0 -1
  178. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/_metadata.json +0 -178
  179. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/attachments-BOv1rBKp.js +0 -3875
  180. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/attachments-BOv1rBKp.js.map +0 -1
  181. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/constants-B9vgIdCd.js +0 -21
  182. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/constants-B9vgIdCd.js.map +0 -1
  183. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/dev-T1qbW_qD.js +0 -1938
  184. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/dev-T1qbW_qD.js.map +0 -1
  185. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/events-Bo11SfVK.js +0 -205
  186. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/events-Bo11SfVK.js.map +0 -1
  187. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/flags-X7D4vfrZ.js +0 -18
  188. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/flags-X7D4vfrZ.js.map +0 -1
  189. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/hydration-oyUje5i1.js +0 -264
  190. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/hydration-oyUje5i1.js.map +0 -1
  191. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/legacy-client-DeSQgSFm.js +0 -697
  192. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/legacy-client-DeSQgSFm.js.map +0 -1
  193. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/package.json +0 -3
  194. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte.js +0 -134
  195. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte.js.map +0 -1
  196. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_animate.js +0 -55
  197. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_animate.js.map +0 -1
  198. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_attachments.js +0 -2
  199. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_compiler.js +0 -46843
  200. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_compiler.js.map +0 -1
  201. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_easing.js +0 -237
  202. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_easing.js.map +0 -1
  203. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_events.js +0 -2
  204. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal.js +0 -4
  205. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal.js.map +0 -1
  206. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_client.js +0 -7
  207. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_disclose-version.js +0 -4
  208. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_disclose-version.js.map +0 -1
  209. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_async.js +0 -5
  210. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_async.js.map +0 -1
  211. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_legacy.js +0 -5
  212. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_legacy.js.map +0 -1
  213. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_tracing.js +0 -5
  214. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_tracing.js.map +0 -1
  215. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_server.js +0 -6
  216. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_legacy.js +0 -83
  217. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_legacy.js.map +0 -1
  218. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_motion.js +0 -559
  219. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_motion.js.map +0 -1
  220. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity.js +0 -24
  221. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity.js.map +0 -1
  222. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity_window.js +0 -122
  223. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity_window.js.map +0 -1
  224. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_server.js +0 -2
  225. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_store.js +0 -89
  226. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_store.js.map +0 -1
  227. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_transition.js +0 -241
  228. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_transition.js.map +0 -1
  229. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-BbWyXNk1.js +0 -4938
  230. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-BbWyXNk1.js.map +0 -1
  231. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-DI_fC7vt.js +0 -495
  232. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-DI_fC7vt.js.map +0 -1
  233. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/validate-Dyr3lQv9.js +0 -190
  234. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/validate-Dyr3lQv9.js.map +0 -1
  235. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/version-_9FHH-LF.js +0 -8
  236. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/version-_9FHH-LF.js.map +0 -1
  237. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/warnings-CUbSPnyP.js +0 -138
  238. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/warnings-CUbSPnyP.js.map +0 -1
@@ -156,18 +156,19 @@ function blockMarkup(entity, schemaVar, typeName, block, resolve, ctx = { hasRec
156
156
  const emptyMsg = `No ${(entity.label ?? entity.name).toLowerCase()} yet.`;
157
157
  // Scheduler view: render the grid's rows as a calendar (a view of the grid, like the
158
158
  // board). Full-client over `allRows`; the enterprise renderer is enabled app-wide.
159
+ const gridIcons = gridIconParts(cfg, idSafe);
159
160
  if (cfg.scheduler) {
160
161
  const schDisp = relationDisplayFields(ctx.rawEntity ?? entity, resolve);
161
162
  const schAsText = (f) => schDisp.get(f) ?? f;
162
- return ` <div ${span}${cls}>
163
- <SvGrid
164
- data={allRows}
165
- columns={${colVar}}
166
- getRowId={(r) => String((r as Record<string, unknown>)[idField])}
167
- loading={!allRowsReady}
168
- scheduler={${schedulerConfigExpr(cfg.scheduler, typeName, schAsText)}}
169
- containerHeight=${ctx.pane ? '"100%"' : `{${block.height ?? 640}}`}
170
- />
163
+ return ` <div ${span}${cls}>
164
+ ${gridIcons.decls} <SvGrid
165
+ data={allRows}
166
+ columns={${colVar}}
167
+ getRowId={(r) => String((r as Record<string, unknown>)[idField])}
168
+ loading={!allRowsReady}
169
+ scheduler={${schedulerConfigExpr(cfg.scheduler, typeName, schAsText)}}
170
+ ${gridIcons.attr ? ` ${gridIcons.attr}\n` : ''} containerHeight=${ctx.pane ? '"100%"' : `{${block.height ?? 640}}`}
171
+ />
171
172
  </div>`;
172
173
  }
173
174
  // Grouped + tree grids both load the full dataset and render client-side (a grouped
@@ -305,6 +306,9 @@ function blockMarkup(entity, schemaVar, typeName, block, resolve, ctx = { hasRec
305
306
  else if (apiBody.length)
306
307
  lines.push(`onApiReady={(a) => { ${apiBody.join('; ')} }}`);
307
308
  lines.push(`containerHeight=${ctx.pane ? '"100%"' : `{${block.height ?? 360}}`}`);
309
+ // No-code icon overrides -> one snippet each, declared just below.
310
+ if (gridIcons.attr)
311
+ lines.push(gridIcons.attr);
308
312
  // Raw "All properties" overrides: pass through every grid prop the curated
309
313
  // controls didn't already emit (deduped by prop name), so nothing is set twice.
310
314
  if (cfg.props && Object.keys(cfg.props).length) {
@@ -317,22 +321,22 @@ function blockMarkup(entity, schemaVar, typeName, block, resolve, ctx = { hasRec
317
321
  }
318
322
  // No-code export toolbar - buttons wired to the grid's own export API.
319
323
  const exportBar = gridHasExport(cfg) && ctx.captureApi ? exportToolbarMarkup(cfg.export, ctx.captureApi, entity.name) : '';
320
- return ` <div ${span}${cls}>
321
- ${exportBar} <SvGrid
322
- ${lines.join('\n ')}
323
- />
324
+ return ` <div ${span}${cls}>
325
+ ${gridIcons.decls}${exportBar} <SvGrid
326
+ ${lines.join('\n ')}
327
+ />
324
328
  </div>`;
325
329
  }
326
330
  case 'chart': {
327
331
  const drillRoute = cfg.drillScreen && ctx.routeById?.get(cfg.drillScreen);
328
332
  const onDrill = drillRoute ? ` onDrill={(cat) => goto('/${drillRoute}?${cfg.dimension}=' + encodeURIComponent(String(cat)))}` : '';
329
- return ` <div ${span}${cls}>
330
- <SvSchemaChart schema={${schemaVar}} rows={${rowsExpr}} dimension="${cfg.dimension}"${cfg.measure ? ` measure="${cfg.measure}"` : ''} reduce="${cfg.reduce}" type="${cfg.type}"${block.height ? ` height={${block.height}}` : ''} controls={false} accent="${chartColorExpr(cfg.color)}"${cfg.dataLabels === false ? ' dataLabels={false}' : ''}${onDrill} />
333
+ return ` <div ${span}${cls}>
334
+ <SvSchemaChart schema={${schemaVar}} rows={${rowsExpr}} dimension="${cfg.dimension}"${cfg.measure ? ` measure="${cfg.measure}"` : ''} reduce="${cfg.reduce}" type="${cfg.type}"${block.height ? ` height={${block.height}}` : ''} controls={false} accent="${chartColorExpr(cfg.color)}"${cfg.dataLabels === false ? ' dataLabels={false}' : ''}${onDrill} />
331
335
  </div>`;
332
336
  }
333
337
  case 'dashboard':
334
- return ` <div ${span}${cls}>
335
- <SvSchemaDashboard schema={${schemaVar}} rows={${rowsExpr}} />
338
+ return ` <div ${span}${cls}>
339
+ <SvSchemaDashboard schema={${schemaVar}} rows={${rowsExpr}} />
336
340
  </div>`;
337
341
  case 'kpi': {
338
342
  const measurePart = cfg.measure ? `measure: '${cfg.measure}', ` : '';
@@ -360,9 +364,9 @@ ${exportBar} <SvGrid
360
364
  const deltaChip = cfg.target == null
361
365
  ? `\n {@const _d = seriesDelta(_s)}\n {#if _d != null}<span class="kpi__delta" class:is-up={_d >= 0} class:is-down={_d < 0}>{_d >= 0 ? '▲' : '▼'} {Math.abs(_d).toFixed(0)}%</span>{/if}`
362
366
  : '';
363
- rows.push(` {#if ${seriesExpr}.length > 1}
364
- {@const _s = ${seriesExpr}}${deltaChip}
365
- <svg class="kpi__spark" viewBox="0 0 120 30" preserveAspectRatio="none" aria-hidden="true"><polyline points={sparklinePoints(_s)} fill="none" stroke="currentColor" stroke-width="1.5" vector-effect="non-scaling-stroke" /></svg>
367
+ rows.push(` {#if ${seriesExpr}.length > 1}
368
+ {@const _s = ${seriesExpr}}${deltaChip}
369
+ <svg class="kpi__spark" viewBox="0 0 120 30" preserveAspectRatio="none" aria-hidden="true"><polyline points={sparklinePoints(_s)} fill="none" stroke="currentColor" stroke-width="1.5" vector-effect="non-scaling-stroke" /></svg>
366
370
  {/if}`);
367
371
  }
368
372
  return ` <div ${span}${wrapperClass(block, 'kpi')}>\n${rows.join('\n')}\n </div>`;
@@ -370,9 +374,9 @@ ${exportBar} <SvGrid
370
374
  case 'gauge': {
371
375
  const gexpr = `reduceValue(${rowsExpr}, { ${cfg.measure ? `measure: '${cfg.measure}', ` : ''}reduce: '${cfg.reduce}' })`;
372
376
  const unit = cfg.unit ? ` unit=${JSON.stringify(cfg.unit)}` : '';
373
- return ` <div ${span}${wrapperClass(block, 'gaugecard')}>
374
- <span class="kpi__label">${tLabel(cfg.label, block.id)}</span>
375
- <SvGauge value={${gexpr}} min={${cfg.min}} max={${cfg.max}}${unit} size={172} />
377
+ return ` <div ${span}${wrapperClass(block, 'gaugecard')}>
378
+ <span class="kpi__label">${tLabel(cfg.label, block.id)}</span>
379
+ <SvGauge value={${gexpr}} min={${cfg.min}} max={${cfg.max}}${unit} size={172} />
376
380
  </div>`;
377
381
  }
378
382
  case 'tree': {
@@ -380,8 +384,8 @@ ${exportBar} <SvGrid
380
384
  return ` <div ${span}${cls}><!-- tree: set a label field + a self-referential parent field in the inspector --></div>`;
381
385
  }
382
386
  const idExpr = `${schemaVar}.idField ?? 'id'`;
383
- return ` <div ${span}${wrapperClass(block, 'treecard')}>
384
- <SvTree nodes={toTreeNodes(${rowsExpr} as Record<string, unknown>[], ${idExpr}, ${JSON.stringify(cfg.labelField)}, ${JSON.stringify(cfg.parentField)})} />
387
+ return ` <div ${span}${wrapperClass(block, 'treecard')}>
388
+ <SvTree nodes={toTreeNodes(${rowsExpr} as Record<string, unknown>[], ${idExpr}, ${JSON.stringify(cfg.labelField)}, ${JSON.stringify(cfg.parentField)})} />
385
389
  </div>`;
386
390
  }
387
391
  case 'tabs': {
@@ -394,19 +398,19 @@ ${exportBar} <SvGrid
394
398
  const panels = cfg.tabs
395
399
  .map((t, i) => {
396
400
  const children = t.blocks.map((cb) => blockMarkup(entity, schemaVar, typeName, cb, resolve, ctx)).filter(Boolean).join('\n');
397
- return ` {#if id === '${tabId(block.id, i)}'}
398
- <div class="st-screen">
399
- ${children || ' <p style="color: var(--sg-muted, #94a3b8); font-size: 13px; padding: 10px;">This tab is empty.</p>'}
400
- </div>
401
+ return ` {#if id === '${tabId(block.id, i)}'}
402
+ <div class="st-screen">
403
+ ${children || ' <p style="color: var(--sg-muted, #94a3b8); font-size: 13px; padding: 10px;">This tab is empty.</p>'}
404
+ </div>
401
405
  {/if}`;
402
406
  })
403
407
  .join('\n');
404
- return ` <div ${span}${cls}>
405
- <SvTabs tabs={${items}} value={${tabsVar}} onChange={(id) => (${tabsVar} = id)}>
406
- {#snippet panel(id)}
407
- ${panels}
408
- {/snippet}
409
- </SvTabs>
408
+ return ` <div ${span}${cls}>
409
+ <SvTabs tabs={${items}} value={${tabsVar}} onChange={(id) => (${tabsVar} = id)}>
410
+ {#snippet panel(id)}
411
+ ${panels}
412
+ {/snippet}
413
+ </SvTabs>
410
414
  </div>`;
411
415
  }
412
416
  case 'accordion': {
@@ -417,19 +421,19 @@ ${panels}
417
421
  const panels = cfg.sections
418
422
  .map((s, i) => {
419
423
  const children = s.blocks.map((cb) => blockMarkup(entity, schemaVar, typeName, cb, resolve, ctx)).filter(Boolean).join('\n');
420
- return ` {#if item.id === '${accSectionId(block.id, i)}'}
421
- <div class="st-screen">
422
- ${children || ' <p style="color: var(--sg-muted, #94a3b8); font-size: 13px; padding: 10px;">This section is empty.</p>'}
423
- </div>
424
+ return ` {#if item.id === '${accSectionId(block.id, i)}'}
425
+ <div class="st-screen">
426
+ ${children || ' <p style="color: var(--sg-muted, #94a3b8); font-size: 13px; padding: 10px;">This section is empty.</p>'}
427
+ </div>
424
428
  {/if}`;
425
429
  })
426
430
  .join('\n');
427
- return ` <div ${span}${cls}>
428
- <SvAccordion items={${items}} expandMode="${cfg.multiple ? 'multiple' : 'single'}" expanded={${accVar}} onChange={(ids) => (${accVar} = ids)}>
429
- {#snippet panel(item)}
430
- ${panels}
431
- {/snippet}
432
- </SvAccordion>
431
+ return ` <div ${span}${cls}>
432
+ <SvAccordion items={${items}} expandMode="${cfg.multiple ? 'multiple' : 'single'}" expanded={${accVar}} onChange={(ids) => (${accVar} = ids)}>
433
+ {#snippet panel(item)}
434
+ ${panels}
435
+ {/snippet}
436
+ </SvAccordion>
433
437
  </div>`;
434
438
  }
435
439
  case 'master-detail': {
@@ -443,14 +447,14 @@ ${panels}
443
447
  // of expanding inline - the detail page shows the same children as a timeline.
444
448
  const mdRoute = cfg.linkScreen ? ctx.routeById?.get(cfg.linkScreen) : undefined;
445
449
  const onParent = mdRoute ? ` onParentClick={(id) => goto('/${mdRoute}?id=' + encodeURIComponent(id))}` : '';
446
- return ` <div ${span}${cls}>
447
- <SvGridMasterDetail schema={${schemaVar}} data={allRows} detailSchema={${cn.schemaVar}} getChildren={(p) => ${childRows}.filter((c) => String((c as Record<string, unknown>)['${cfg.foreignKey}']) === String((p as Record<string, unknown>)[${schemaVar}.idField ?? 'id']))} rowHeight={30}${onParent}${block.height ? ` containerHeight={${block.height}}` : ''} />
450
+ return ` <div ${span}${cls}>
451
+ <SvGridMasterDetail schema={${schemaVar}} data={allRows} detailSchema={${cn.schemaVar}} getChildren={(p) => ${childRows}.filter((c) => String((c as Record<string, unknown>)['${cfg.foreignKey}']) === String((p as Record<string, unknown>)[${schemaVar}.idField ?? 'id']))} rowHeight={30}${onParent}${block.height ? ` containerHeight={${block.height}}` : ''} />
448
452
  </div>`;
449
453
  }
450
454
  case 'pivot': {
451
455
  const h = block.height ?? 460;
452
- return ` <div ${wrapperStyle(block, `height: ${h}px`)}${cls}>
453
- <SvPivotDesigner data={${rowsExpr}} fields={${pivotFieldsExpr(entity)}} layout={${pivotLayoutExpr(cfg)}} />
456
+ return ` <div ${wrapperStyle(block, `height: ${h}px`)}${cls}>
457
+ <SvPivotDesigner data={${rowsExpr}} fields={${pivotFieldsExpr(entity)}} layout={${pivotLayoutExpr(cfg)}} />
454
458
  </div>`;
455
459
  }
456
460
  case 'filter':
@@ -472,8 +476,8 @@ ${panels}
472
476
  const openRoute = cfg.openScreen ? ctx.routeById?.get(cfg.openScreen) : undefined;
473
477
  const onOpen = openRoute ? ` onOpen={(id) => goto('/${openRoute}?id=' + encodeURIComponent(id))}` : '';
474
478
  // Dragging a card updates its groupBy value in the local row state (optimistic).
475
- return ` <div ${wrapperStyle(block)}${cls}>
476
- <SvBoard schema={${schemaVar}} rows={allRows} loading={!allRowsReady} groupBy=${JSON.stringify(cfg.groupBy)} titleField=${JSON.stringify(asText(cfg.titleField))}${badge}${sub}${onOpen} height={${h}} onMove={(id, value) => { allRows = allRows.map((r) => String((r as Record<string, unknown>)[idField]) === String(id) ? ({ ...r, ['${cfg.groupBy}']: value }) : r) }} />
479
+ return ` <div ${wrapperStyle(block)}${cls}>
480
+ <SvBoard schema={${schemaVar}} rows={allRows} loading={!allRowsReady} groupBy=${JSON.stringify(cfg.groupBy)} titleField=${JSON.stringify(asText(cfg.titleField))}${badge}${sub}${onOpen} height={${h}} onMove={(id, value) => { allRows = allRows.map((r) => String((r as Record<string, unknown>)[idField]) === String(id) ? ({ ...r, ['${cfg.groupBy}']: value }) : r) }} />
477
481
  </div>`;
478
482
  }
479
483
  case 'calendar': {
@@ -487,8 +491,8 @@ ${panels}
487
491
  // + recurrence): a richer view of the grid's rows with a detail drawer. `openScreen`
488
492
  // navigation is superseded by the in-place drawer.
489
493
  const sc = { startField: cfg.dateField, titleField: cfg.titleField, colorField: cfg.colorField, drawer: true };
490
- return ` <div ${wrapperStyle(block)}${cls}>
491
- <SvGrid data={allRows} columns={schemaToColumns(${schemaVar})} getRowId={(r) => String((r as Record<string, unknown>)[${schemaVar}.idField ?? 'id'])} loading={!allRowsReady} scheduler={${schedulerConfigExpr(sc, typeName, asText)}} containerHeight={${h}} />
494
+ return ` <div ${wrapperStyle(block)}${cls}>
495
+ <SvGrid data={allRows} columns={schemaToColumns(${schemaVar})} getRowId={(r) => String((r as Record<string, unknown>)[${schemaVar}.idField ?? 'id'])} loading={!allRowsReady} scheduler={${schedulerConfigExpr(sc, typeName, asText)}} containerHeight={${h}} />
492
496
  </div>`;
493
497
  }
494
498
  case 'detail': {
@@ -536,8 +540,8 @@ ${panels}
536
540
  props.push(`selectedId={page.url.searchParams.get('id') ?? undefined}`);
537
541
  if (h)
538
542
  props.push(`height={${h}}`);
539
- return ` <div ${wrapperStyle(block)}${cls}>
540
- <SvRecordDetail ${props.join(' ')} />
543
+ return ` <div ${wrapperStyle(block)}${cls}>
544
+ <SvRecordDetail ${props.join(' ')} />
541
545
  </div>`;
542
546
  }
543
547
  case 'lookup':
@@ -571,12 +575,12 @@ function createFormMarkup(entity, schemaVar, block, cfg) {
571
575
  // makes the new screen the confirmation.
572
576
  const done = cfg.afterSave === 'navigate'
573
577
  ? ''
574
- : `
578
+ : `
575
579
  {#if formSaves}<p class="st-form__done" role="status">Saved. Add another below.</p>{/if}`;
576
- return ` <div ${span}${cls}>${done}
577
- {#key formSaves}
578
- <SvGridEditPanel ${attrs.join(' ')} onSubmit={createRecord} />
579
- {/key}
580
+ return ` <div ${span}${cls}>${done}
581
+ {#key formSaves}
582
+ <SvGridEditPanel ${attrs.join(' ')} onSubmit={createRecord} />
583
+ {/key}
580
584
  </div>`;
581
585
  }
582
586
  /** Emits a UI-kit component block (see `UI_COMPONENT_REGISTRY`): a literal
@@ -620,9 +624,9 @@ function componentBlockMarkup(block, cfg, handleName, rowsExpr) {
620
624
  const wrapper = STANDARD_UI_EVENTS.filter((se) => !propWired.some((e) => e.key === se.key))
621
625
  .map((se) => ` on${se.dom}={(e) => ${handleName}.fire('${se.key}', e)}`)
622
626
  .join('');
623
- return ` <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
624
- <div id="${block.id}"${wrapper} ${span}${cls}>
625
- <${spec.importName} {...${handleName}.props}${eventAttrs}${inner}
627
+ return ` <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
628
+ <div id="${block.id}"${wrapper} ${span}${cls}>
629
+ <${spec.importName} {...${handleName}.props}${eventAttrs}${inner}
626
630
  </div>`;
627
631
  }
628
632
  // Data bindings: a bound prop's value is a reactive expression over the screen's
@@ -658,8 +662,8 @@ function componentBlockMarkup(block, cfg, handleName, rowsExpr) {
658
662
  : contentB
659
663
  ? `>{${bindingExpr(contentB, rowsExpr, false)}}</${spec.importName}>`
660
664
  : `>{${jsStr(String(cfg.props._content ?? spec.contentDefault ?? ''))}}</${spec.importName}>`;
661
- return ` <div id="${block.id}" ${span}${cls}>
662
- ${openTag}${inner}
665
+ return ` <div id="${block.id}" ${span}${cls}>
666
+ ${openTag}${inner}
663
667
  </div>`;
664
668
  }
665
669
  /** A JS expression for a component prop's configured value, by panel type. */
@@ -749,6 +753,36 @@ function conditionalFormatsExpr(cfg) {
749
753
  }
750
754
  return entries.length ? `[${entries.join(', ')}]` : null;
751
755
  }
756
+ /** Snippet declarations + the `icons={...}` attribute for a grid's no-code icon
757
+ * overrides. SvGrid's `icons` prop takes a map of SNIPPETS, so each override has to
758
+ * become a real snippet declared next to the grid; it cannot be a value the way
759
+ * every other prop in the panel is. That is the whole reason icons get their own
760
+ * config field instead of riding along in `props`, which is JSON.stringify'd.
761
+ *
762
+ * A value that reads as a tag is emitted as markup, so a user can paste an inline
763
+ * `<svg>` or reference a component they imported in code-behind. Anything else goes
764
+ * through an expression rather than as bare template text, so a glyph containing a
765
+ * brace, a quote or an angle bracket cannot break the page it lands in. That last
766
+ * case is not hypothetical: `<` and `>` are perfectly good pager arrows, which is
767
+ * why this looks for a whole tag rather than just a leading bracket.
768
+ *
769
+ * Snippets are declared inside the block's own wrapper and prefixed with the block
770
+ * id, so two grids on one screen can override the same icon differently. */
771
+ function gridIconParts(cfg, idSafe) {
772
+ const entries = Object.entries(cfg.icons ?? {}).filter(([name, v]) => name && typeof v === 'string' && v.trim() !== '');
773
+ if (!entries.length)
774
+ return { decls: '', attr: '' };
775
+ const decls = [];
776
+ const pairs = [];
777
+ for (const [name, glyph] of entries) {
778
+ const snip = `icon_${idSafe}_${name.replace(/[^A-Za-z0-9]/g, '_')}`;
779
+ const g = glyph.trim();
780
+ const body = /^<[a-zA-Z][\s\S]*>$/.test(g) ? g : `{${jsStr(glyph)}}`;
781
+ decls.push(` {#snippet ${snip}()}${body}{/snippet}`);
782
+ pairs.push(`${jsStr(name)}: ${snip}`);
783
+ }
784
+ return { decls: decls.join('\n') + '\n', attr: `icons={{ ${pairs.join(', ')} }}` };
785
+ }
752
786
  const fieldLabel = (f) => f.label ?? f.field;
753
787
  /** Stable per-block identifiers for a filter panel's state + apply function. */
754
788
  const facetNames = (block) => {
@@ -794,12 +828,12 @@ function filterPanelState(entity, block, cfg) {
794
828
  return ` if (v[${key}]) c[${key}] = { operator: 'equals', value: v[${key}] }`;
795
829
  return ` if (v[${key}]) c[${key}] = { operator: 'contains', value: v[${key}] }`;
796
830
  }).join('\n');
797
- return `let ${state} = $state<Record<string, string>>({})
798
- function ${apply}() {
799
- const v = ${state}
800
- const c: Record<string, { operator: 'equals' | 'contains'; value: string }> = {}
801
- ${assigns}
802
- controller.setFilter({ columns: c })
831
+ return `let ${state} = $state<Record<string, string>>({})
832
+ function ${apply}() {
833
+ const v = ${state}
834
+ const c: Record<string, { operator: 'equals' | 'contains'; value: string }> = {}
835
+ ${assigns}
836
+ controller.setFilter({ columns: c })
803
837
  }`;
804
838
  }
805
839
  /** The faceted filter sidebar markup, wired to its facet state. */
@@ -809,22 +843,22 @@ function filterPanelMarkup(entity, block, cfg) {
809
843
  const set = `${state}[${jsStr(f.field)}] = e.currentTarget.value; ${apply}()`;
810
844
  if (f.type === 'enum') {
811
845
  const opts = enumOpts(f).map((o) => `<option value="${o.value}">${o.label}</option>`).join('');
812
- return ` <label class="st-filter__row"><span>${fieldLabel(f)}</span>
813
- <select onchange={(e) => { ${set} }}><option value="">Any</option>${opts}</select>
846
+ return ` <label class="st-filter__row"><span>${fieldLabel(f)}</span>
847
+ <select onchange={(e) => { ${set} }}><option value="">Any</option>${opts}</select>
814
848
  </label>`;
815
849
  }
816
850
  if (f.type === 'boolean') {
817
- return ` <label class="st-filter__row"><span>${fieldLabel(f)}</span>
818
- <select onchange={(e) => { ${set} }}><option value="">Any</option><option value="true">Yes</option><option value="false">No</option></select>
851
+ return ` <label class="st-filter__row"><span>${fieldLabel(f)}</span>
852
+ <select onchange={(e) => { ${set} }}><option value="">Any</option><option value="true">Yes</option><option value="false">No</option></select>
819
853
  </label>`;
820
854
  }
821
- return ` <label class="st-filter__row"><span>${fieldLabel(f)}</span>
822
- <input type="search" placeholder="Search…" oninput={(e) => { ${set} }} />
855
+ return ` <label class="st-filter__row"><span>${fieldLabel(f)}</span>
856
+ <input type="search" placeholder="Search…" oninput={(e) => { ${set} }} />
823
857
  </label>`;
824
858
  }).join('\n');
825
- return ` <aside ${wrapperStyle(block)}${wrapperClass(block, 'st-filter')}>
826
- <div class="st-filter__title">${cfg.title ?? 'Filters'}</div>
827
- ${controls}
859
+ return ` <aside ${wrapperStyle(block)}${wrapperClass(block, 'st-filter')}>
860
+ <div class="st-filter__title">${cfg.title ?? 'Filters'}</div>
861
+ ${controls}
828
862
  </aside>`;
829
863
  }
830
864
  /** A `{#snippet}` rendering a grid row's action buttons (edit / delete / navigate).
@@ -857,10 +891,10 @@ function rowActionsSnippet(idSafe, typeName, entity, actions, routeById, gate, s
857
891
  const target = a.targetField ?? idField;
858
892
  return `<button type="button" class="st-rowaction" onclick={(e) => { e.stopPropagation(); goto('/${route}?${target}=' + encodeURIComponent(String((row as Record<string, unknown>)[${jsStr(src)}] ?? ''))) }}>${a.label ?? 'Open'}</button>`;
859
893
  }).filter(Boolean).join('\n ');
860
- return `{#snippet rowActions_${idSafe}({ row }: { row: ${typeName} })}
861
- <div class="st-rowactions">
862
- ${buttons}
863
- </div>
894
+ return `{#snippet rowActions_${idSafe}({ row }: { row: ${typeName} })}
895
+ <div class="st-rowactions">
896
+ ${buttons}
897
+ </div>
864
898
  {/snippet}`;
865
899
  }
866
900
  /** Sanitize a user action id into a valid JS identifier suffix. */
@@ -936,38 +970,38 @@ function filterSurfaceProps(cfg) {
936
970
  function treeGridScript(idSafe, typeName, cfg) {
937
971
  const parent = jsStr(cfg.treeData.parentField);
938
972
  const rec = `(r as Record<string, unknown>)`;
939
- return `let treeExpanded_${idSafe} = $state<Record<string, boolean>>({})
940
- function toggleTree_${idSafe}(id: string) { treeExpanded_${idSafe} = { ...treeExpanded_${idSafe}, [id]: !(treeExpanded_${idSafe}[id] ?? true) } }
941
- function treeBuild_${idSafe}() {
942
- const rows = allRows
943
- const idOf = (r: ${typeName}) => String(${rec}[idField] ?? '')
944
- const parentOf = (r: ${typeName}) => { const v = ${rec}[${parent}]; return v == null || v === '' ? null : String(v) }
945
- const present = new Set(rows.map(idOf))
946
- const children = new Map<string | null, ${typeName}[]>()
947
- for (const r of rows) { const p = parentOf(r); const key = p != null && present.has(p) ? p : null; const list = children.get(key) ?? []; list.push(r); children.set(key, list) }
948
- const info = new Map<string, { depth: number; hasChildren: boolean; expanded: boolean }>()
949
- const visible: ${typeName}[] = []
950
- const walk = (parentId: string | null, depth: number) => {
951
- for (const r of children.get(parentId) ?? []) {
952
- const id = idOf(r); const hasChildren = (children.get(id)?.length ?? 0) > 0; const expanded = treeExpanded_${idSafe}[id] ?? true
953
- info.set(id, { depth, hasChildren, expanded }); visible.push(r)
954
- if (hasChildren && expanded) walk(id, depth + 1)
955
- }
956
- }
957
- walk(null, 0)
958
- return { info, visible }
959
- }
973
+ return `let treeExpanded_${idSafe} = $state<Record<string, boolean>>({})
974
+ function toggleTree_${idSafe}(id: string) { treeExpanded_${idSafe} = { ...treeExpanded_${idSafe}, [id]: !(treeExpanded_${idSafe}[id] ?? true) } }
975
+ function treeBuild_${idSafe}() {
976
+ const rows = allRows
977
+ const idOf = (r: ${typeName}) => String(${rec}[idField] ?? '')
978
+ const parentOf = (r: ${typeName}) => { const v = ${rec}[${parent}]; return v == null || v === '' ? null : String(v) }
979
+ const present = new Set(rows.map(idOf))
980
+ const children = new Map<string | null, ${typeName}[]>()
981
+ for (const r of rows) { const p = parentOf(r); const key = p != null && present.has(p) ? p : null; const list = children.get(key) ?? []; list.push(r); children.set(key, list) }
982
+ const info = new Map<string, { depth: number; hasChildren: boolean; expanded: boolean }>()
983
+ const visible: ${typeName}[] = []
984
+ const walk = (parentId: string | null, depth: number) => {
985
+ for (const r of children.get(parentId) ?? []) {
986
+ const id = idOf(r); const hasChildren = (children.get(id)?.length ?? 0) > 0; const expanded = treeExpanded_${idSafe}[id] ?? true
987
+ info.set(id, { depth, hasChildren, expanded }); visible.push(r)
988
+ if (hasChildren && expanded) walk(id, depth + 1)
989
+ }
990
+ }
991
+ walk(null, 0)
992
+ return { info, visible }
993
+ }
960
994
  const tree_${idSafe} = $derived(treeBuild_${idSafe}())`;
961
995
  }
962
996
  /** The `{#snippet}` for a tree grid's label column: indent by depth + an expand toggle. */
963
997
  function treeCellSnippet(idSafe, typeName) {
964
998
  const idExpr = `String((row as Record<string, unknown>)[idField] ?? '')`;
965
- return `{#snippet treeCell_${idSafe}({ value, row }: { value: unknown; row: ${typeName} })}
966
- {@const info = tree_${idSafe}.info.get(${idExpr})}
967
- <span class="st-treecell" style="padding-left: {(info?.depth ?? 0) * 18}px;">
968
- {#if info?.hasChildren}<button type="button" class="st-tree-toggle" aria-label="Toggle" aria-expanded={info.expanded} onclick={() => toggleTree_${idSafe}(${idExpr})}>{info.expanded ? '▾' : '▸'}</button>{:else}<span class="st-tree-spacer"></span>{/if}
969
- <span>{String(value ?? '')}</span>
970
- </span>
999
+ return `{#snippet treeCell_${idSafe}({ value, row }: { value: unknown; row: ${typeName} })}
1000
+ {@const info = tree_${idSafe}.info.get(${idExpr})}
1001
+ <span class="st-treecell" style="padding-left: {(info?.depth ?? 0) * 18}px;">
1002
+ {#if info?.hasChildren}<button type="button" class="st-tree-toggle" aria-label="Toggle" aria-expanded={info.expanded} onclick={() => toggleTree_${idSafe}(${idExpr})}>{info.expanded ? '▾' : '▸'}</button>{:else}<span class="st-tree-spacer"></span>{/if}
1003
+ <span>{String(value ?? '')}</span>
1004
+ </span>
971
1005
  {/snippet}`;
972
1006
  }
973
1007
  /** The export toolbar markup for a grid: buttons wired to the captured grid API var. */
@@ -993,36 +1027,36 @@ function exportToolbarMarkup(e, apiVar, entityName) {
993
1027
  }
994
1028
  /** Shared helper: map a status-ish value to a badge intent by common vocabulary.
995
1029
  * Emitted once per page when any column uses the `badge` cell renderer. */
996
- const BADGE_VARIANT_HELPER = ` function stBadgeVariant(value: unknown): 'neutral' | 'success' | 'warning' | 'danger' | 'info' {
997
- const v = String(value ?? '').toLowerCase().trim()
998
- if (['active', 'open', 'success', 'done', 'paid', 'approved', 'complete', 'completed', 'won', 'shipped', 'yes', 'true'].includes(v)) return 'success'
999
- if (['pending', 'warning', 'in progress', 'processing', 'trial', 'review', 'on hold', 'medium'].includes(v)) return 'warning'
1000
- if (['closed', 'error', 'failed', 'cancelled', 'canceled', 'overdue', 'rejected', 'lost', 'blocked', 'inactive', 'no', 'false', 'high', 'urgent'].includes(v)) return 'danger'
1001
- if (['new', 'info', 'draft', 'low'].includes(v)) return 'info'
1002
- return 'neutral'
1030
+ const BADGE_VARIANT_HELPER = ` function stBadgeVariant(value: unknown): 'neutral' | 'success' | 'warning' | 'danger' | 'info' {
1031
+ const v = String(value ?? '').toLowerCase().trim()
1032
+ if (['active', 'open', 'success', 'done', 'paid', 'approved', 'complete', 'completed', 'won', 'shipped', 'yes', 'true'].includes(v)) return 'success'
1033
+ if (['pending', 'warning', 'in progress', 'processing', 'trial', 'review', 'on hold', 'medium'].includes(v)) return 'warning'
1034
+ if (['closed', 'error', 'failed', 'cancelled', 'canceled', 'overdue', 'rejected', 'lost', 'blocked', 'inactive', 'no', 'false', 'high', 'urgent'].includes(v)) return 'danger'
1035
+ if (['new', 'info', 'draft', 'low'].includes(v)) return 'info'
1036
+ return 'neutral'
1003
1037
  }`;
1004
1038
  /** Shared helper: run an `@svgrid/enterprise` export off a captured grid API.
1005
1039
  * Emitted once per page when any grid has an xlsx / pdf button. Surfaces the
1006
1040
  * failure instead of swallowing it - a missing optional peer dep (jszip for
1007
1041
  * xlsx, pdfmake for pdf) is the usual cause and is worth seeing. */
1008
- const EXPORT_HELPER = ` async function stExport(api: SvGridApi<never, never> | undefined, format: 'xlsx' | 'pdf', filename: string) {
1009
- if (!api) return
1010
- try {
1011
- await exportGrid(api, { format, filename })
1012
- } catch (err) {
1013
- console.error('Export failed:', err)
1014
- alert('Export failed: ' + (err instanceof Error ? err.message : String(err)))
1015
- }
1042
+ const EXPORT_HELPER = ` async function stExport(api: SvGridApi<never, never> | undefined, format: 'xlsx' | 'pdf', filename: string) {
1043
+ if (!api) return
1044
+ try {
1045
+ await exportGrid(api, { format, filename })
1046
+ } catch (err) {
1047
+ console.error('Export failed:', err)
1048
+ alert('Export failed: ' + (err instanceof Error ? err.message : String(err)))
1049
+ }
1016
1050
  }`;
1017
1051
  /** Shared helper: paginated print off a captured grid API. */
1018
- const PRINT_HELPER = ` async function stPrint(api: SvGridApi<never, never> | undefined, title: string) {
1019
- if (!api) return
1020
- try {
1021
- await printGrid(api, { title })
1022
- } catch (err) {
1023
- console.error('Print failed:', err)
1024
- alert('Print failed: ' + (err instanceof Error ? err.message : String(err)))
1025
- }
1052
+ const PRINT_HELPER = ` async function stPrint(api: SvGridApi<never, never> | undefined, title: string) {
1053
+ if (!api) return
1054
+ try {
1055
+ await printGrid(api, { title })
1056
+ } catch (err) {
1057
+ console.error('Print failed:', err)
1058
+ alert('Print failed: ' + (err instanceof Error ? err.message : String(err)))
1059
+ }
1026
1060
  }`;
1027
1061
  /** The `{#snippet}` body for one rich cell renderer (badge / progress / link). */
1028
1062
  function cellRendererSnippet(idSafe, field, cellType) {
@@ -1045,17 +1079,17 @@ function cellRendererSnippet(idSafe, field, cellType) {
1045
1079
  * assumed - swap for the app's own if it has one). */
1046
1080
  function actionHandlerScript(a) {
1047
1081
  const safe = actionIdSafe(a.id);
1048
- return `let actionBusy_${safe} = $state(false)
1049
- async function runAction_${safe}(payload?: Record<string, unknown>) {
1050
- actionBusy_${safe} = true
1051
- try {
1052
- const res = await fetch('/api/actions/${a.id}', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload ?? {}) })
1053
- if (!res.ok) throw new Error(((await res.json().catch(() => ({}))) as { error?: string }).error ?? 'Action failed')
1054
- } catch (err) {
1055
- alert(err instanceof Error ? err.message : 'Action failed')
1056
- } finally {
1057
- actionBusy_${safe} = false
1058
- }
1082
+ return `let actionBusy_${safe} = $state(false)
1083
+ async function runAction_${safe}(payload?: Record<string, unknown>) {
1084
+ actionBusy_${safe} = true
1085
+ try {
1086
+ const res = await fetch('/api/actions/${a.id}', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload ?? {}) })
1087
+ if (!res.ok) throw new Error(((await res.json().catch(() => ({}))) as { error?: string }).error ?? 'Action failed')
1088
+ } catch (err) {
1089
+ alert(err instanceof Error ? err.message : 'Action failed')
1090
+ } finally {
1091
+ actionBusy_${safe} = false
1092
+ }
1059
1093
  }`;
1060
1094
  }
1061
1095
  /** A toolbar action button - RBAC-gated by screen access (not a CRUD `can()`
@@ -1077,15 +1111,15 @@ function actionRouteFile(a, screenId, accessEnabled) {
1077
1111
  return {
1078
1112
  path: `src/routes/api/actions/${a.id}/+server.ts`,
1079
1113
  description: `Stub route for the "${a.label}" action - fill in the actual logic.`,
1080
- contents: `${accessImport}
1081
- export async function POST(event: { request: Request; locals?: Record<string, unknown> }) {${guard}
1082
- const body = await event.request.json().catch(() => ({})) as Record<string, unknown>
1083
- void body // the row id (row actions) or {} (toolbar actions) - use it to look up what to act on
1084
-
1085
- // TODO: your business logic here.
1086
-
1087
- return new Response(JSON.stringify({ ok: true }))
1088
- }
1114
+ contents: `${accessImport}
1115
+ export async function POST(event: { request: Request; locals?: Record<string, unknown> }) {${guard}
1116
+ const body = await event.request.json().catch(() => ({})) as Record<string, unknown>
1117
+ void body // the row id (row actions) or {} (toolbar actions) - use it to look up what to act on
1118
+
1119
+ // TODO: your business logic here.
1120
+
1121
+ return new Response(JSON.stringify({ ok: true }))
1122
+ }
1089
1123
  `,
1090
1124
  };
1091
1125
  }
@@ -1118,14 +1152,14 @@ function recordPanelMarkup(entity, schemaVar, block, cfg) {
1118
1152
  // Modal / drawer float over the page (shown only while a row is selected);
1119
1153
  // inline lives in the block, with a prompt when nothing is selected.
1120
1154
  inner = pres === 'inline'
1121
- ? ` {#if selectedRecord}
1122
- <SvGridEditPanel schema={${schemaVar}} row={selectedRecord} presentation="inline" onSubmit={saveRecord} onCancel={() => (selectedRecord = null)} />
1123
- {:else}
1124
- <p class="st-hint">Select a row to see its details.</p>
1155
+ ? ` {#if selectedRecord}
1156
+ <SvGridEditPanel schema={${schemaVar}} row={selectedRecord} presentation="inline" onSubmit={saveRecord} onCancel={() => (selectedRecord = null)} />
1157
+ {:else}
1158
+ <p class="st-hint">Select a row to see its details.</p>
1125
1159
  {/if}`
1126
- : ` <p class="st-hint">Select a row to open its ${pres === 'drawer' ? 'editor drawer' : 'edit dialog'}.</p>
1127
- {#if selectedRecord}
1128
- <SvGridEditPanel schema={${schemaVar}} row={selectedRecord} presentation="${pres}" onSubmit={saveRecord} onCancel={() => (selectedRecord = null)} />
1160
+ : ` <p class="st-hint">Select a row to open its ${pres === 'drawer' ? 'editor drawer' : 'edit dialog'}.</p>
1161
+ {#if selectedRecord}
1162
+ <SvGridEditPanel schema={${schemaVar}} row={selectedRecord} presentation="${pres}" onSubmit={saveRecord} onCancel={() => (selectedRecord = null)} />
1129
1163
  {/if}`;
1130
1164
  }
1131
1165
  else {
@@ -1134,16 +1168,16 @@ function recordPanelMarkup(entity, schemaVar, block, cfg) {
1134
1168
  ? entity.fields.filter((f) => cfg.fields.includes(f.field))
1135
1169
  : entity.fields.filter((f) => f.field !== pk);
1136
1170
  const rows = chosen.map((f) => ` <div class="st-record__row"><dt>${fieldLabel(f)}</dt><dd>{String((selectedRecord as Record<string, unknown>)[${jsStr(f.field)}] ?? '')}</dd></div>`).join('\n');
1137
- inner = ` {#if selectedRecord}
1138
- <dl class="st-record">
1139
- ${rows}
1140
- </dl>
1141
- {:else}
1142
- <p class="st-hint">Select a row to see its details.</p>
1171
+ inner = ` {#if selectedRecord}
1172
+ <dl class="st-record">
1173
+ ${rows}
1174
+ </dl>
1175
+ {:else}
1176
+ <p class="st-hint">Select a row to see its details.</p>
1143
1177
  {/if}`;
1144
1178
  }
1145
- return ` <div ${span}${wrapperClass(block, 'st-record-card')}>
1146
- ${inner}
1179
+ return ` <div ${span}${wrapperClass(block, 'st-record-card')}>
1180
+ ${inner}
1147
1181
  </div>`;
1148
1182
  }
1149
1183
  /** A freestanding page - no bound entity, so no entity-bound `Block`; it can
@@ -1310,56 +1344,56 @@ export function ctxCompletions(screen) {
1310
1344
  * (so calls never false-error) while keeping useful returns (Promise<string>,
1311
1345
  * boolean, ...). Self-contained: the only external reference is the `Row` param.
1312
1346
  * Powers the in-editor TypeScript service's hover / signature / diagnostics. */
1313
- const GRID_API_SIGNATURES = ` getCellValue(rowIndex: number, columnId: string): unknown
1314
- setCellValue(rowIndex: number, columnId: string, value: unknown): void
1315
- startEditing(rowIndex: number, columnId: string): boolean
1316
- stopEditing(cancel?: boolean): boolean
1317
- selectCells(ranges: ReadonlyArray<readonly [number, number, number, number]>): void
1318
- getSelected(): Array<[number, number, number, number]>
1319
- openChart(): void
1320
- closeChart(): void
1321
- getChartSpec(): unknown
1322
- chartRange(ranges?: ReadonlyArray<readonly [number, number, number, number]>): void
1323
- configureChart(config: { open?: boolean; type?: string; dimension?: string | null; series?: string | null; measure?: string | null; reduce?: 'sum' | 'avg' | 'count'; stacked?: boolean; dataLabels?: boolean; logScale?: boolean; timeAxis?: boolean; valueFormat?: 'number' | 'currency' | 'percent' | 'compact' }): void
1324
- setChartAiHandler(handler: ((prompt: string) => Promise<Record<string, unknown> | null>) | null): void
1325
- addRow(row: Row, position?: 'top' | 'bottom' | number): void
1326
- addRows(rows: ReadonlyArray<Row>, position?: 'top' | 'bottom' | number): void
1327
- removeRow(rowIndex: number): void
1328
- removeRows(rowIndices: ReadonlyArray<number>): void
1329
- applyTransaction(tx: { add?: Row[]; update?: Row[]; remove?: Array<string | Row> }): { added: number; updated: number; removed: number }
1330
- addColumn(column: any, position?: 'left' | 'right' | number): void
1331
- addColumns(columns: ReadonlyArray<any>, position?: 'left' | 'right' | number): void
1332
- removeColumn(columnId: string): void
1333
- setColumnVisible(columnId: string, visible: boolean): void
1334
- isColumnVisible(columnId: string): boolean
1335
- setSort(columnId: string, direction: 'asc' | 'desc' | null): void
1336
- clearSort(): void
1337
- setGroupBy(columnIds: ReadonlyArray<string>): void
1338
- setFilter(columnId: string, filter: any | null): void
1339
- setFacetFilter(columnId: string, values: ReadonlyArray<string> | null): void
1340
- clearFilter(columnId: string): void
1341
- clearAllFilters(): void
1342
- getFilters(): Record<string, { operator: string; value: string; valueTo?: string }>
1343
- getDisplayedRows(): ReadonlyArray<Row>
1344
- getData(): ReadonlyArray<Row>
1345
- getColumns(): ReadonlyArray<{ id: string; header: string; visible: boolean }>
1346
- exportCsv(options?: any): Promise<string>
1347
- exportTsv(options?: any): Promise<string>
1348
- exportJson(options?: any): Promise<string>
1349
- copyToClipboard(options?: any): Promise<string>
1350
- clearRowSelection(): void
1351
- setColumnWidth(columnId: string, width: number): void
1352
- getColumnWidths(): Record<string, number>
1353
- autosizeColumn(columnId: string): void
1354
- autosizeAllColumns(): void
1355
- setColumnPinning(pinning: { left?: string[]; right?: string[] }): void
1356
- getColumnPinning(): { left: string[]; right: string[] }
1357
- setColumnOrder(order: ReadonlyArray<string>): void
1358
- getColumnOrder(): string[]
1359
- setRowExpanded(id: string, expanded: boolean): void
1360
- expandAllGroups(): void
1361
- collapseAllGroups(): void
1362
- undo(): boolean
1347
+ const GRID_API_SIGNATURES = ` getCellValue(rowIndex: number, columnId: string): unknown
1348
+ setCellValue(rowIndex: number, columnId: string, value: unknown): void
1349
+ startEditing(rowIndex: number, columnId: string): boolean
1350
+ stopEditing(cancel?: boolean): boolean
1351
+ selectCells(ranges: ReadonlyArray<readonly [number, number, number, number]>): void
1352
+ getSelected(): Array<[number, number, number, number]>
1353
+ openChart(): void
1354
+ closeChart(): void
1355
+ getChartSpec(): unknown
1356
+ chartRange(ranges?: ReadonlyArray<readonly [number, number, number, number]>): void
1357
+ configureChart(config: { open?: boolean; type?: string; dimension?: string | null; series?: string | null; measure?: string | null; reduce?: 'sum' | 'avg' | 'count'; stacked?: boolean; dataLabels?: boolean; logScale?: boolean; timeAxis?: boolean; valueFormat?: 'number' | 'currency' | 'percent' | 'compact' }): void
1358
+ setChartAiHandler(handler: ((prompt: string) => Promise<Record<string, unknown> | null>) | null): void
1359
+ addRow(row: Row, position?: 'top' | 'bottom' | number): void
1360
+ addRows(rows: ReadonlyArray<Row>, position?: 'top' | 'bottom' | number): void
1361
+ removeRow(rowIndex: number): void
1362
+ removeRows(rowIndices: ReadonlyArray<number>): void
1363
+ applyTransaction(tx: { add?: Row[]; update?: Row[]; remove?: Array<string | Row> }): { added: number; updated: number; removed: number }
1364
+ addColumn(column: any, position?: 'left' | 'right' | number): void
1365
+ addColumns(columns: ReadonlyArray<any>, position?: 'left' | 'right' | number): void
1366
+ removeColumn(columnId: string): void
1367
+ setColumnVisible(columnId: string, visible: boolean): void
1368
+ isColumnVisible(columnId: string): boolean
1369
+ setSort(columnId: string, direction: 'asc' | 'desc' | null): void
1370
+ clearSort(): void
1371
+ setGroupBy(columnIds: ReadonlyArray<string>): void
1372
+ setFilter(columnId: string, filter: any | null): void
1373
+ setFacetFilter(columnId: string, values: ReadonlyArray<string> | null): void
1374
+ clearFilter(columnId: string): void
1375
+ clearAllFilters(): void
1376
+ getFilters(): Record<string, { operator: string; value: string; valueTo?: string }>
1377
+ getDisplayedRows(): ReadonlyArray<Row>
1378
+ getData(): ReadonlyArray<Row>
1379
+ getColumns(): ReadonlyArray<{ id: string; header: string; visible: boolean }>
1380
+ exportCsv(options?: any): Promise<string>
1381
+ exportTsv(options?: any): Promise<string>
1382
+ exportJson(options?: any): Promise<string>
1383
+ copyToClipboard(options?: any): Promise<string>
1384
+ clearRowSelection(): void
1385
+ setColumnWidth(columnId: string, width: number): void
1386
+ getColumnWidths(): Record<string, number>
1387
+ autosizeColumn(columnId: string): void
1388
+ autosizeAllColumns(): void
1389
+ setColumnPinning(pinning: { left?: string[]; right?: string[] }): void
1390
+ getColumnPinning(): { left: string[]; right: string[] }
1391
+ setColumnOrder(order: ReadonlyArray<string>): void
1392
+ getColumnOrder(): string[]
1393
+ setRowExpanded(id: string, expanded: boolean): void
1394
+ expandAllGroups(): void
1395
+ collapseAllGroups(): void
1396
+ undo(): boolean
1363
1397
  redo(): boolean`;
1364
1398
  /** Map an entity field to a TS type for the generated Row interface (mirrors scaffold.ts). */
1365
1399
  function fieldTsType(f) {
@@ -1396,35 +1430,35 @@ export function ctxAmbientDts(screen, entity) {
1396
1430
  members.push(` data: { rows: ${rowName}[]; reload(): void }`);
1397
1431
  members.push(' goto(path: string): void');
1398
1432
  members.push(' params: Record<string, string>');
1399
- return `// Ambient types for the "${screen.title}" screen's code-behind. Regenerated - editor use only.
1400
- ${rowDecl}
1401
-
1402
- interface SvGridApi<Row> {
1403
- ${GRID_API_SIGNATURES}
1404
- }
1405
-
1406
- interface DataHandle<T> {
1407
- /** Feed this block its own rows (overrides the screen dataset until cleared). */
1408
- setData(rows: T[]): void
1409
- /** The rows the block renders: the override if set, else the screen dataset. */
1410
- readonly rows: T[]
1411
- /** Drop the override; the block follows the screen dataset again. */
1412
- clear(): void
1413
- }
1414
-
1415
- interface Handle {
1416
- setText(value: string): void
1417
- setLabel(value: string): void
1418
- set(name: string, value: unknown): void
1419
- get(name: string): unknown
1420
- onClick(fn: (e: Event) => void): void
1421
- onclick: (e: Event) => void
1422
- [key: string]: any
1423
- }
1424
- ${componentTypeDecls ? '\n' + componentTypeDecls + '\n' : ''}
1425
- interface PageContext {
1426
- ${members.join('\n')}
1427
- }
1433
+ return `// Ambient types for the "${screen.title}" screen's code-behind. Regenerated - editor use only.
1434
+ ${rowDecl}
1435
+
1436
+ interface SvGridApi<Row> {
1437
+ ${GRID_API_SIGNATURES}
1438
+ }
1439
+
1440
+ interface DataHandle<T> {
1441
+ /** Feed this block its own rows (overrides the screen dataset until cleared). */
1442
+ setData(rows: T[]): void
1443
+ /** The rows the block renders: the override if set, else the screen dataset. */
1444
+ readonly rows: T[]
1445
+ /** Drop the override; the block follows the screen dataset again. */
1446
+ clear(): void
1447
+ }
1448
+
1449
+ interface Handle {
1450
+ setText(value: string): void
1451
+ setLabel(value: string): void
1452
+ set(name: string, value: unknown): void
1453
+ get(name: string): unknown
1454
+ onClick(fn: (e: Event) => void): void
1455
+ onclick: (e: Event) => void
1456
+ [key: string]: any
1457
+ }
1458
+ ${componentTypeDecls ? '\n' + componentTypeDecls + '\n' : ''}
1459
+ interface PageContext {
1460
+ ${members.join('\n')}
1461
+ }
1428
1462
  `;
1429
1463
  }
1430
1464
  /** A comment manifest of what `ctx` gives onLoad/onDestroy - each handle plus the
@@ -1457,103 +1491,103 @@ function handlesModuleFile() {
1457
1491
  return {
1458
1492
  path: 'src/lib/handles.svelte.ts',
1459
1493
  description: 'Reactive imperative handles for UI components (btn.setLabel, btn.onclick = ...).',
1460
- contents: `// Regenerated by SvGrid Studio.
1461
- /** An imperative, reactive handle over a UI component. In page code you get one
1462
- * per component (e.g. \`button1\`); mutate it and the component updates. */
1463
- export class ComponentHandle {
1464
- props = $state<Record<string, unknown>>({})
1465
- text = $state('')
1466
- on = $state<Record<string, (e: Event) => void>>({})
1467
- constructor(init: { props?: Record<string, unknown>; text?: string }) {
1468
- this.props = { ...(init.props ?? {}) }
1469
- this.text = init.text ?? ''
1470
- }
1471
- set(name: string, value: unknown): this { this.props = { ...this.props, [name]: value }; return this }
1472
- get(name: string): unknown { return this.props[name] }
1473
- setText(value: string): this { this.text = value; return this }
1474
- setLabel(value: string): this { this.text = value; return this }
1475
- onEvent(name: string, fn: (e: Event) => void): this { this.on = { ...this.on, [name.toLowerCase()]: fn }; return this }
1476
- onClick(fn: (e: Event) => void): this { return this.onEvent('click', fn) }
1477
- fire(name: string, e: Event): void { this.on[name.toLowerCase()]?.(e) }
1478
- }
1479
-
1480
- /** An imperative, reactive handle over a data-bound block (chart, KPI, gauge,
1481
- * pivot, ...). By default it mirrors the screen's dataset; \`setData(rows)\` pins
1482
- * an override so page code can feed the block its own rows (a filtered slice, a
1483
- * fetch result), and \`clear()\` returns it to the screen dataset. */
1484
- export class DataHandle<T = Record<string, unknown>> {
1485
- #override = $state<T[] | null>(null)
1486
- #fallback: () => T[]
1487
- constructor(fallback: () => T[]) { this.#fallback = fallback }
1488
- /** The rows the block renders: the override if set, else the screen dataset. */
1489
- get rows(): T[] { return this.#override ?? this.#fallback() }
1490
- /** Feed this block its own rows (overrides the screen dataset until cleared). */
1491
- setData(rows: T[]): void { this.#override = rows }
1492
- /** Drop the override; the block follows the screen dataset again. */
1493
- clear(): void { this.#override = null }
1494
- }
1495
-
1496
- /** A DataHandle whose fallback is the screen dataset getter. */
1497
- export function dataHandle<T>(fallback: () => T[]): DataHandle<T> { return new DataHandle<T>(fallback) }
1498
-
1499
- /** Event subscriptions for the Grid (\`ctx.grid.onCellClick = (e) => {}\`). The keys
1500
- * are lowercased on both store + dispatch so any on<Event> casing round-trips. */
1501
- class GridEvents {
1502
- on: Record<string, (...args: unknown[]) => void> = {}
1503
- fire(name: string, ...args: unknown[]): void { this.on[name.toLowerCase()]?.(...args) }
1504
- }
1505
- /** Wrap the Grid's real SvGridApi so page code gets BOTH its methods
1506
- * (\`ctx.grid.exportCsv()\`) and event subscription (\`ctx.grid.onRowClick = fn\`).
1507
- * The api arrives asynchronously (onApiReady), so it is read lazily via \`getApi\`. */
1508
- export function gridHandle<A extends object>(getApi: () => A | null): A & GridEvents {
1509
- const ev = new GridEvents()
1510
- return new Proxy(ev, {
1511
- get(t, k) {
1512
- if (k in t) { const v = (t as Record<string | symbol, unknown>)[k]; return typeof v === 'function' ? (v as (...a: unknown[]) => unknown).bind(t) : v }
1513
- const api = getApi() as Record<string | symbol, unknown> | null
1514
- if (!api) return undefined
1515
- const v = api[k]
1516
- if (typeof v === 'function') return (v as (...a: unknown[]) => unknown).bind(api)
1517
- // A grid PROP read (ctx.grid.sortable) -> its effective value via getOption.
1518
- if (v === undefined && typeof k === 'string' && !/^on[A-Z]/.test(k) && typeof api.getOption === 'function') return (api.getOption as (key: unknown) => unknown)(k)
1519
- return v
1520
- },
1521
- set(t, k, val) {
1522
- // onRowClick = fn / oncellclick = fn -> subscribe (any casing; keys lowercase).
1523
- if (typeof k === 'string' && /^on[A-Za-z]/.test(k)) { t.on = { ...t.on, [k.slice(2).toLowerCase()]: val as (...a: unknown[]) => void }; return true }
1524
- const api = getApi() as Record<string | symbol, unknown> | null
1525
- // ctx.grid.sortable = true -> the grid's reactive option channel (setOption writes
1526
- // a $state override the grid merges over its props). Falls back to a direct set for
1527
- // an older grid build without setOption.
1528
- if (api && typeof api.setOption === 'function') { (api.setOption as (key: unknown, value: unknown) => void)(k, val); return true }
1529
- if (api) api[k] = val
1530
- return true
1531
- },
1532
- }) as A & GridEvents
1533
- }
1534
-
1535
- /** A handle plus dynamic setX / onX helpers and \`el.onclick = fn\` assignment. */
1536
- export type Handle = ComponentHandle & Record<string, any>
1537
-
1538
- export function handle(init: { props?: Record<string, unknown>; text?: string }): Handle {
1539
- const h = new ComponentHandle(init)
1540
- return new Proxy(h, {
1541
- get(t, k) {
1542
- if (Reflect.has(t, k)) { const v = (t as unknown as Record<string, unknown>)[k as string]; return typeof v === 'function' ? v.bind(t) : v }
1543
- if (typeof k === 'string' && /^set[A-Z]/.test(k)) { const p = k[3]!.toLowerCase() + k.slice(4); return (v: unknown) => t.set(p, v) }
1544
- // onEvent(fn) call form. onEvent lowercases, so onKeyDown maps to the 'keydown' the wrapper fires.
1545
- if (typeof k === 'string' && /^on[A-Z]/.test(k)) { const e = k.slice(2); return (fn: (ev: Event) => void) => t.onEvent(e, fn) }
1546
- return typeof k === 'string' ? t.props[k] : undefined
1547
- },
1548
- set(t, k, v) {
1549
- // el.onKeyDown = fn / el.onclick = fn -> subscribe (onEvent lowercases the key).
1550
- if (typeof k === 'string' && /^on[A-Za-z]/.test(k)) { t.onEvent(k.slice(2), v as (e: Event) => void); return true }
1551
- if (k === 'text') { t.text = v as string; return true } // content, not a prop
1552
- if (typeof k === 'string') t.set(k, v) // checkbox1.checked = true
1553
- return true
1554
- },
1555
- }) as Handle
1556
- }
1494
+ contents: `// Regenerated by SvGrid Studio.
1495
+ /** An imperative, reactive handle over a UI component. In page code you get one
1496
+ * per component (e.g. \`button1\`); mutate it and the component updates. */
1497
+ export class ComponentHandle {
1498
+ props = $state<Record<string, unknown>>({})
1499
+ text = $state('')
1500
+ on = $state<Record<string, (e: Event) => void>>({})
1501
+ constructor(init: { props?: Record<string, unknown>; text?: string }) {
1502
+ this.props = { ...(init.props ?? {}) }
1503
+ this.text = init.text ?? ''
1504
+ }
1505
+ set(name: string, value: unknown): this { this.props = { ...this.props, [name]: value }; return this }
1506
+ get(name: string): unknown { return this.props[name] }
1507
+ setText(value: string): this { this.text = value; return this }
1508
+ setLabel(value: string): this { this.text = value; return this }
1509
+ onEvent(name: string, fn: (e: Event) => void): this { this.on = { ...this.on, [name.toLowerCase()]: fn }; return this }
1510
+ onClick(fn: (e: Event) => void): this { return this.onEvent('click', fn) }
1511
+ fire(name: string, e: Event): void { this.on[name.toLowerCase()]?.(e) }
1512
+ }
1513
+
1514
+ /** An imperative, reactive handle over a data-bound block (chart, KPI, gauge,
1515
+ * pivot, ...). By default it mirrors the screen's dataset; \`setData(rows)\` pins
1516
+ * an override so page code can feed the block its own rows (a filtered slice, a
1517
+ * fetch result), and \`clear()\` returns it to the screen dataset. */
1518
+ export class DataHandle<T = Record<string, unknown>> {
1519
+ #override = $state<T[] | null>(null)
1520
+ #fallback: () => T[]
1521
+ constructor(fallback: () => T[]) { this.#fallback = fallback }
1522
+ /** The rows the block renders: the override if set, else the screen dataset. */
1523
+ get rows(): T[] { return this.#override ?? this.#fallback() }
1524
+ /** Feed this block its own rows (overrides the screen dataset until cleared). */
1525
+ setData(rows: T[]): void { this.#override = rows }
1526
+ /** Drop the override; the block follows the screen dataset again. */
1527
+ clear(): void { this.#override = null }
1528
+ }
1529
+
1530
+ /** A DataHandle whose fallback is the screen dataset getter. */
1531
+ export function dataHandle<T>(fallback: () => T[]): DataHandle<T> { return new DataHandle<T>(fallback) }
1532
+
1533
+ /** Event subscriptions for the Grid (\`ctx.grid.onCellClick = (e) => {}\`). The keys
1534
+ * are lowercased on both store + dispatch so any on<Event> casing round-trips. */
1535
+ class GridEvents {
1536
+ on: Record<string, (...args: unknown[]) => void> = {}
1537
+ fire(name: string, ...args: unknown[]): void { this.on[name.toLowerCase()]?.(...args) }
1538
+ }
1539
+ /** Wrap the Grid's real SvGridApi so page code gets BOTH its methods
1540
+ * (\`ctx.grid.exportCsv()\`) and event subscription (\`ctx.grid.onRowClick = fn\`).
1541
+ * The api arrives asynchronously (onApiReady), so it is read lazily via \`getApi\`. */
1542
+ export function gridHandle<A extends object>(getApi: () => A | null): A & GridEvents {
1543
+ const ev = new GridEvents()
1544
+ return new Proxy(ev, {
1545
+ get(t, k) {
1546
+ if (k in t) { const v = (t as Record<string | symbol, unknown>)[k]; return typeof v === 'function' ? (v as (...a: unknown[]) => unknown).bind(t) : v }
1547
+ const api = getApi() as Record<string | symbol, unknown> | null
1548
+ if (!api) return undefined
1549
+ const v = api[k]
1550
+ if (typeof v === 'function') return (v as (...a: unknown[]) => unknown).bind(api)
1551
+ // A grid PROP read (ctx.grid.sortable) -> its effective value via getOption.
1552
+ if (v === undefined && typeof k === 'string' && !/^on[A-Z]/.test(k) && typeof api.getOption === 'function') return (api.getOption as (key: unknown) => unknown)(k)
1553
+ return v
1554
+ },
1555
+ set(t, k, val) {
1556
+ // onRowClick = fn / oncellclick = fn -> subscribe (any casing; keys lowercase).
1557
+ if (typeof k === 'string' && /^on[A-Za-z]/.test(k)) { t.on = { ...t.on, [k.slice(2).toLowerCase()]: val as (...a: unknown[]) => void }; return true }
1558
+ const api = getApi() as Record<string | symbol, unknown> | null
1559
+ // ctx.grid.sortable = true -> the grid's reactive option channel (setOption writes
1560
+ // a $state override the grid merges over its props). Falls back to a direct set for
1561
+ // an older grid build without setOption.
1562
+ if (api && typeof api.setOption === 'function') { (api.setOption as (key: unknown, value: unknown) => void)(k, val); return true }
1563
+ if (api) api[k] = val
1564
+ return true
1565
+ },
1566
+ }) as A & GridEvents
1567
+ }
1568
+
1569
+ /** A handle plus dynamic setX / onX helpers and \`el.onclick = fn\` assignment. */
1570
+ export type Handle = ComponentHandle & Record<string, any>
1571
+
1572
+ export function handle(init: { props?: Record<string, unknown>; text?: string }): Handle {
1573
+ const h = new ComponentHandle(init)
1574
+ return new Proxy(h, {
1575
+ get(t, k) {
1576
+ if (Reflect.has(t, k)) { const v = (t as unknown as Record<string, unknown>)[k as string]; return typeof v === 'function' ? v.bind(t) : v }
1577
+ if (typeof k === 'string' && /^set[A-Z]/.test(k)) { const p = k[3]!.toLowerCase() + k.slice(4); return (v: unknown) => t.set(p, v) }
1578
+ // onEvent(fn) call form. onEvent lowercases, so onKeyDown maps to the 'keydown' the wrapper fires.
1579
+ if (typeof k === 'string' && /^on[A-Z]/.test(k)) { const e = k.slice(2); return (fn: (ev: Event) => void) => t.onEvent(e, fn) }
1580
+ return typeof k === 'string' ? t.props[k] : undefined
1581
+ },
1582
+ set(t, k, v) {
1583
+ // el.onKeyDown = fn / el.onclick = fn -> subscribe (onEvent lowercases the key).
1584
+ if (typeof k === 'string' && /^on[A-Za-z]/.test(k)) { t.onEvent(k.slice(2), v as (e: Event) => void); return true }
1585
+ if (k === 'text') { t.text = v as string; return true } // content, not a prop
1586
+ if (typeof k === 'string') t.set(k, v) // checkbox1.checked = true
1587
+ return true
1588
+ },
1589
+ }) as Handle
1590
+ }
1557
1591
  `,
1558
1592
  };
1559
1593
  }
@@ -1684,12 +1718,12 @@ function screenContextFile(screen, rowType) {
1684
1718
  return {
1685
1719
  path: `src/routes/${screen.route}/page-context.ts`,
1686
1720
  description: `Typed page context for "${screen.title}" (regenerated).`,
1687
- contents: `// Regenerated by SvGrid Studio. Edits here are overwritten - write code in handlers.ts.
1688
- ${imports}${imports ? '\n\n' : ''}${typeDecls ? typeDecls + '\n\n' : ''}/** What onLoad(ctx) / onDestroy(ctx) give you: the Grid's real API, each block as a
1689
- * handle, the screen dataset, and goto / params. */
1690
- export type PageContext = {
1691
- ${members.join('\n')}
1692
- }
1721
+ contents: `// Regenerated by SvGrid Studio. Edits here are overwritten - write code in handlers.ts.
1722
+ ${imports}${imports ? '\n\n' : ''}${typeDecls ? typeDecls + '\n\n' : ''}/** What onLoad(ctx) / onDestroy(ctx) give you: the Grid's real API, each block as a
1723
+ * handle, the screen dataset, and goto / params. */
1724
+ export type PageContext = {
1725
+ ${members.join('\n')}
1726
+ }
1693
1727
  `,
1694
1728
  };
1695
1729
  }
@@ -1732,9 +1766,9 @@ function compiledMethodBodies(screen) {
1732
1766
  * behavior here. Scaffolded once (userOwned) and never regenerated - the page
1733
1767
  * imports it, never rewrites it. See HANDLERS-DESIGN.md. */
1734
1768
  function screenHandlersFile(screen) {
1735
- const header = `// Your code for the "${screen.title}" screen.
1736
- // SvGrid Studio scaffolds this file once and never overwrites it - it's yours.
1737
- // Design the screen visually in Studio; write its behavior here.
1769
+ const header = `// Your code for the "${screen.title}" screen.
1770
+ // SvGrid Studio scaffolds this file once and never overwrites it - it's yours.
1771
+ // Design the screen visually in Studio; write its behavior here.
1738
1772
  ${screenElementsManifest(screen)}`;
1739
1773
  let body;
1740
1774
  if (screen.handlersSource) {
@@ -1753,18 +1787,18 @@ ${screenElementsManifest(screen)}`;
1753
1787
  const loadInner = loadRaw ? indentBody(loadRaw) : ' // Runs when the page mounts. Reach blocks via ctx.<name>, feed data via ctx.data / ctx.<chart>.setData(rows).';
1754
1788
  const destroyRaw = compiled.onDestroy ?? screen.handlerBodies?.[ON_DESTROY]?.trim();
1755
1789
  const destroyInner = destroyRaw ? indentBody(destroyRaw) : ' // Runs when the page unmounts. Clean up timers, subscriptions, aborts.';
1756
- body = `import type { PageContext } from './page-context'
1757
-
1758
- /** Runs when the page mounts. Reach blocks (ctx.grid.exportCsv(), ctx.chart1.setData(rows),
1759
- * ctx.button1.onclick = () => {}), fetch data, navigate with ctx.goto. */
1760
- export async function ${ON_LOAD}(ctx: PageContext): Promise<void> {
1761
- ${loadInner}
1762
- }
1763
-
1764
- /** Runs when the page unmounts. Clean up anything onLoad started. */
1765
- export function ${ON_DESTROY}(ctx: PageContext): void {
1766
- ${destroyInner}
1767
- }
1790
+ body = `import type { PageContext } from './page-context'
1791
+
1792
+ /** Runs when the page mounts. Reach blocks (ctx.grid.exportCsv(), ctx.chart1.setData(rows),
1793
+ * ctx.button1.onclick = () => {}), fetch data, navigate with ctx.goto. */
1794
+ export async function ${ON_LOAD}(ctx: PageContext): Promise<void> {
1795
+ ${loadInner}
1796
+ }
1797
+
1798
+ /** Runs when the page unmounts. Clean up anything onLoad started. */
1799
+ export function ${ON_DESTROY}(ctx: PageContext): void {
1800
+ ${destroyInner}
1801
+ }
1768
1802
  `;
1769
1803
  }
1770
1804
  return {
@@ -1811,13 +1845,13 @@ function freestandingScreenPage(screen, accessEnabled, i18nEnabled) {
1811
1845
  ? `\n let rows = $state<RowData[]>([])\n let gridApi = $state<SvGridApi<any, any> | null>(null)\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature, rowSelectionFeature })\n const columns = $derived(rows.length ? Object.keys(rows[0]).map((field) => ({ field, header: field })) : [])`
1812
1846
  : '';
1813
1847
  const codeScript = hasCode
1814
- ? `\n ${gridScript ? gridScript.trimStart() + '\n ' : ''}${handleDecls ? handleDecls + '\n ' : ''}onMount(() => {
1815
- // ctx is internal plumbing wired from this screen's blocks; the typed surface your
1816
- // code uses lives in handlers.ts (PageContext). Component handles are runtime proxies,
1817
- // so the cast bridges their dynamic shape to the typed context.
1818
- const ctx = ${wiring.ctxLiteral} as unknown as PageContext
1819
- handlers.${ON_LOAD}(ctx)
1820
- return () => handlers.${ON_DESTROY}(ctx)
1848
+ ? `\n ${gridScript ? gridScript.trimStart() + '\n ' : ''}${handleDecls ? handleDecls + '\n ' : ''}onMount(() => {
1849
+ // ctx is internal plumbing wired from this screen's blocks; the typed surface your
1850
+ // code uses lives in handlers.ts (PageContext). Component handles are runtime proxies,
1851
+ // so the cast bridges their dynamic shape to the typed context.
1852
+ const ctx = ${wiring.ctxLiteral} as unknown as PageContext
1853
+ handlers.${ON_LOAD}(ctx)
1854
+ return () => handlers.${ON_DESTROY}(ctx)
1821
1855
  })`
1822
1856
  : '';
1823
1857
  // Grid events fire into ctx.grid so freestanding page code can subscribe too.
@@ -1831,14 +1865,14 @@ function freestandingScreenPage(screen, accessEnabled, i18nEnabled) {
1831
1865
  return {
1832
1866
  path: `src/routes/${screen.route}/+page.svelte`,
1833
1867
  description: `${screen.title} screen (freestanding, no bound entity).`,
1834
- contents: `<script lang="ts">
1835
- ${gridImport}${typeImport}${handleImport}${codeImport}${gotoImport}${pageStateImport}${accessImport}${i18nImport}${parts.join('\n\n ')}${codeScript}
1836
- </script>
1837
-
1838
- <h1 class="st__title">${title}</h1>
1839
- ${toolbar}<div class="st-screen${screenClassSuffix(screen)}">
1840
- ${content}
1841
- </div>
1868
+ contents: `<script lang="ts">
1869
+ ${gridImport}${typeImport}${handleImport}${codeImport}${gotoImport}${pageStateImport}${accessImport}${i18nImport}${parts.join('\n\n ')}${codeScript}
1870
+ </script>
1871
+
1872
+ <h1 class="st__title">${title}</h1>
1873
+ ${toolbar}<div class="st-screen${screenClassSuffix(screen)}">
1874
+ ${content}
1875
+ </div>
1842
1876
  `,
1843
1877
  };
1844
1878
  }
@@ -2060,9 +2094,9 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
2060
2094
  parts.push(actionHandlerScript(a));
2061
2095
  if (needsController) {
2062
2096
  const urlFilter = applyUrlFilters
2063
- ? `\n const sp = page.url.searchParams
2064
- const _cols: Record<string, { operator: 'equals'; value: string }> = {}
2065
- for (const _f of [${filterableFieldNames.map(jsStr).join(', ')}]) { const _v = sp.get(_f); if (_v != null) _cols[_f] = { operator: 'equals', value: _v } }
2097
+ ? `\n const sp = page.url.searchParams
2098
+ const _cols: Record<string, { operator: 'equals'; value: string }> = {}
2099
+ for (const _f of [${filterableFieldNames.map(jsStr).join(', ')}]) { const _v = sp.get(_f); if (_v != null) _cols[_f] = { operator: 'equals', value: _v } }
2066
2100
  if (Object.keys(_cols).length) controller.setFilter({ columns: _cols })`
2067
2101
  : '';
2068
2102
  const ctlBase = `createServerDataSource<${n.type}>(${n.sourceVar}, { pageSize: ${gridPageSize}, optimistic: true, getRowId: (r) => String((r as Record<string, unknown>)[idField]), onChange: (s) => (view = s) })`;
@@ -2072,12 +2106,12 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
2072
2106
  ? `const __ctl = ${ctlBase}\n const controller = { ...__ctl, createRow: async (i: Partial<${n.type}>) => { const r = await __ctl.createRow(i); gridCtx.fire('rowAdded', r); return r }, updateRow: async (id: string, p: Partial<${n.type}>) => { const r = await __ctl.updateRow(id, p); gridCtx.fire('rowUpdated', r); return r }, deleteRow: async (id: string) => { await __ctl.deleteRow(id); gridCtx.fire('rowDeleted', id) } }`
2073
2107
  : `const controller = ${ctlBase}`;
2074
2108
  const rtWiring = rtSupabase
2075
- ? `\n const __rt = createSupabaseRealtime({ client: supabaseClient as unknown as SupabaseRealtimeClientLike, table: ${jsStr(rtTable)}, debounceMs: 250, onChange: () => controller.refresh() })
2109
+ ? `\n const __rt = createSupabaseRealtime({ client: supabaseClient as unknown as SupabaseRealtimeClientLike, table: ${jsStr(rtTable)}, debounceMs: 250, onChange: () => controller.refresh() })
2076
2110
  return () => { __rt.unsubscribe(); controller.dispose() } })`
2077
2111
  : `\n controller.refresh(); return () => controller.dispose() })`;
2078
- parts.push(`const idField = ${n.schemaVar}.idField ?? 'id'
2079
- let view = $state<ServerState<${n.type}>>({ rows: [], total: 0, loading: false, saving: false, error: null, pageIndex: 0, pageSize: ${gridPageSize}, pageCount: 1, sortModel: [], filterModel: {} })
2080
- ${ctlDecl}
2112
+ parts.push(`const idField = ${n.schemaVar}.idField ?? 'id'
2113
+ let view = $state<ServerState<${n.type}>>({ rows: [], total: 0, loading: false, saving: false, error: null, pageIndex: 0, pageSize: ${gridPageSize}, pageCount: 1, sortModel: [], filterModel: {} })
2114
+ ${ctlDecl}
2081
2115
  $effect(() => {${urlFilter}${rtSupabase ? '\n controller.refresh()' : ''}${rtWiring}`);
2082
2116
  }
2083
2117
  const actionSnippets = [];
@@ -2125,11 +2159,11 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
2125
2159
  // SSR read screens get their rows from the server load (real SSR HTML);
2126
2160
  // the SPA path fetches client-side after mount.
2127
2161
  parts.push(ssrData
2128
- ? `const allRows = $derived(data.rows as ${n.type}[])
2162
+ ? `const allRows = $derived(data.rows as ${n.type}[])
2129
2163
  const allRowsReady = true`
2130
- : `let allRows = $state<${n.type}[]>([])
2131
- let allRowsReady = $state(false)
2132
- async function loadAll() { allRows = [...(await ${n.sourceVar}.getRows({ startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} })).rows]; allRowsReady = true }
2164
+ : `let allRows = $state<${n.type}[]>([])
2165
+ let allRowsReady = $state(false)
2166
+ async function loadAll() { allRows = [...(await ${n.sourceVar}.getRows({ startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} })).rows]; allRowsReady = true }
2133
2167
  loadAll()`);
2134
2168
  }
2135
2169
  // Tree-grid state/derivations - emitted after `allRows` so it's in scope.
@@ -2143,13 +2177,13 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
2143
2177
  // Persist the arrangement to localStorage (restored after mount so SSR stays
2144
2178
  // stable), unless the screen opts out - then the seeded layout is fixed.
2145
2179
  const persistParts = panePersist
2146
- ? `
2147
- function loadDock(): DockManagerState | null { try { const r = localStorage.getItem(${dockKey}); return r ? (JSON.parse(r) as DockManagerState) : null } catch { return null } }
2148
- function saveDock(ws: DockManagerState) { try { localStorage.setItem(${dockKey}, JSON.stringify(ws)) } catch { /* storage unavailable */ } }
2180
+ ? `
2181
+ function loadDock(): DockManagerState | null { try { const r = localStorage.getItem(${dockKey}); return r ? (JSON.parse(r) as DockManagerState) : null } catch { return null } }
2182
+ function saveDock(ws: DockManagerState) { try { localStorage.setItem(${dockKey}, JSON.stringify(ws)) } catch { /* storage unavailable */ } }
2149
2183
  $effect(() => { const saved = loadDock(); if (saved) dockWorkspace = saved })`
2150
2184
  : '';
2151
- parts.push(`let dockWorkspace = $state<DockManagerState>(${JSON.stringify(dockState ?? { main: null, floating: [], autoHide: [] })})
2152
- let dockNarrow = $state(false)${persistParts}
2185
+ parts.push(`let dockWorkspace = $state<DockManagerState>(${JSON.stringify(dockState ?? { main: null, floating: [], autoHide: [] })})
2186
+ let dockNarrow = $state(false)${persistParts}
2153
2187
  $effect(() => { const mq = window.matchMedia('(max-width: 720px)'); const sync = () => (dockNarrow = mq.matches); sync(); mq.addEventListener('change', sync); return () => mq.removeEventListener('change', sync) })`);
2154
2188
  }
2155
2189
  for (const c of childList) {
@@ -2159,8 +2193,8 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
2159
2193
  parts.push(`const ${v} = $derived(data.${v} as ${cn.type}[])`);
2160
2194
  continue;
2161
2195
  }
2162
- parts.push(`let ${v} = $state<${cn.type}[]>([])
2163
- async function load_${v}() { ${v} = [...(await ${cn.sourceVar}.getRows({ startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} })).rows] }
2196
+ parts.push(`let ${v} = $state<${cn.type}[]>([])
2197
+ async function load_${v}() { ${v} = [...(await ${cn.sourceVar}.getRows({ startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} })).rows] }
2164
2198
  load_${v}()`);
2165
2199
  }
2166
2200
  // "On record saved" (formSubmit) steps run at the end of a save, with the
@@ -2173,11 +2207,11 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
2173
2207
  const lookupsProp = relationFields.length
2174
2208
  ? `\n const lookups = { ${relationFields.map((f, i) => `${f.field}: ${lookupVars[i]}`).join(', ')} }`
2175
2209
  : '';
2176
- parts.push(`let editing = $state<${n.type} | null | undefined>(undefined)${lookupsProp}
2177
- async function save({ mode, id, values }: { mode: 'create' | 'edit'; id: string | null; values: Partial<${n.type}> }) {
2178
- if (mode === 'create') { await controller.createRow({ [idField]: nextId('${n.idPrefix}'), ...values } as Partial<${n.type}>); controller.setPage(view.pageCount - 1) }
2179
- else if (id) { await controller.updateRow(id, values) }${submitBody}
2180
- editing = undefined${needsAllRows ? '\n await loadAll()' : ''}
2210
+ parts.push(`let editing = $state<${n.type} | null | undefined>(undefined)${lookupsProp}
2211
+ async function save({ mode, id, values }: { mode: 'create' | 'edit'; id: string | null; values: Partial<${n.type}> }) {
2212
+ if (mode === 'create') { await controller.createRow({ [idField]: nextId('${n.idPrefix}'), ...values } as Partial<${n.type}>); controller.setPage(view.pageCount - 1) }
2213
+ else if (id) { await controller.updateRow(id, values) }${submitBody}
2214
+ editing = undefined${needsAllRows ? '\n await loadAll()' : ''}
2181
2215
  }`);
2182
2216
  }
2183
2217
  // Standalone create form: a counter that both blanks the panel (it is the
@@ -2189,18 +2223,18 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
2189
2223
  const after = nav
2190
2224
  ? `\n await goto('/${nav}')`
2191
2225
  : '';
2192
- parts.push(`let formSaves = $state(0)
2193
- async function createRecord({ values }: { mode: 'create' | 'edit'; id: string | null; values: Partial<${n.type}> }) {
2194
- await controller.createRow({ [idField]: nextId('${n.idPrefix}'), ...values } as Partial<${n.type}>)${submitBody}
2195
- formSaves += 1${after}
2226
+ parts.push(`let formSaves = $state(0)
2227
+ async function createRecord({ values }: { mode: 'create' | 'edit'; id: string | null; values: Partial<${n.type}> }) {
2228
+ await controller.createRow({ [idField]: nextId('${n.idPrefix}'), ...values } as Partial<${n.type}>)${submitBody}
2229
+ formSaves += 1${after}
2196
2230
  }`);
2197
2231
  }
2198
2232
  // Record panel: the row selected in the grid, plus (when editable) a save hook.
2199
2233
  if (hasRecord) {
2200
- parts.push(`let selectedRecord = $state<${n.type} | null>(null)${recordEditable ? `
2201
- async function saveRecord({ id, values }: { mode: 'create' | 'edit'; id: string | null; values: Partial<${n.type}> }) {
2202
- if (id) { await controller.updateRow(id, values) }${submitBody}
2203
- selectedRecord = null${needsAllRows ? '\n await loadAll()' : ''}
2234
+ parts.push(`let selectedRecord = $state<${n.type} | null>(null)${recordEditable ? `
2235
+ async function saveRecord({ id, values }: { mode: 'create' | 'edit'; id: string | null; values: Partial<${n.type}> }) {
2236
+ if (id) { await controller.updateRow(id, values) }${submitBody}
2237
+ selectedRecord = null${needsAllRows ? '\n await loadAll()' : ''}
2204
2238
  }` : ''}`);
2205
2239
  }
2206
2240
  // Filter panel(s): one facet-state object + an apply() that rebuilds the whole
@@ -2224,17 +2258,17 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
2224
2258
  }
2225
2259
  // Tree block: fold the flat rows into SvTree nodes by a self-referential parent.
2226
2260
  if (has(allBlocks, 'tree')) {
2227
- parts.push(`type TreeNode = { id: string; label: string; children: TreeNode[] }
2228
- function toTreeNodes(rows: Record<string, unknown>[], idField: string, labelField: string, parentField: string): TreeNode[] {
2229
- const byId = new Map<string, TreeNode>(rows.map((r) => [String(r[idField]), { id: String(r[idField]), label: String(r[labelField] ?? r[idField]), children: [] }]))
2230
- const roots: TreeNode[] = []
2231
- for (const r of rows) {
2232
- const node = byId.get(String(r[idField]))!
2233
- const pid = r[parentField] != null && r[parentField] !== '' ? String(r[parentField]) : null
2234
- if (pid && pid !== node.id && byId.has(pid)) byId.get(pid)!.children.push(node)
2235
- else roots.push(node)
2236
- }
2237
- return roots
2261
+ parts.push(`type TreeNode = { id: string; label: string; children: TreeNode[] }
2262
+ function toTreeNodes(rows: Record<string, unknown>[], idField: string, labelField: string, parentField: string): TreeNode[] {
2263
+ const byId = new Map<string, TreeNode>(rows.map((r) => [String(r[idField]), { id: String(r[idField]), label: String(r[labelField] ?? r[idField]), children: [] }]))
2264
+ const roots: TreeNode[] = []
2265
+ for (const r of rows) {
2266
+ const node = byId.get(String(r[idField]))!
2267
+ const pid = r[parentField] != null && r[parentField] !== '' ? String(r[parentField]) : null
2268
+ if (pid && pid !== node.id && byId.has(pid)) byId.get(pid)!.children.push(node)
2269
+ else roots.push(node)
2270
+ }
2271
+ return roots
2238
2272
  }`);
2239
2273
  }
2240
2274
  // Code-behind: declare the block handles, then run onLoad on mount + onDestroy on
@@ -2243,10 +2277,10 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
2243
2277
  if (codeWire) {
2244
2278
  if (codeWire.decls.length)
2245
2279
  parts.push(codeWire.decls.join('\n '));
2246
- parts.push(`onMount(() => {
2247
- const ctx = ${codeWire.ctxLiteral} as unknown as PageContext
2248
- handlers.${ON_LOAD}(ctx)
2249
- return () => handlers.${ON_DESTROY}(ctx)
2280
+ parts.push(`onMount(() => {
2281
+ const ctx = ${codeWire.ctxLiteral} as unknown as PageContext
2282
+ handlers.${ON_LOAD}(ctx)
2283
+ return () => handlers.${ON_DESTROY}(ctx)
2250
2284
  })`);
2251
2285
  }
2252
2286
  // --- markup ---
@@ -2277,25 +2311,25 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
2277
2311
  }).filter(Boolean).join('\n')
2278
2312
  : '';
2279
2313
  const screenBody = isDock
2280
- ? `{#if dockNarrow}
2281
- <div class="st-screen${screenClassSuffix(screen)}">
2282
- ${body}
2283
- </div>
2284
- {:else}
2285
- <div class="st-dock">
2286
- <SvDockManager bind:workspace={dockWorkspace}${panePersist ? ' onChange={(w) => saveDock(w)}' : ''}${paneAttrStr}>
2287
- {#snippet pane(p)}
2288
- ${dockPanes}
2289
- {/snippet}
2290
- </SvDockManager>
2291
- </div>
2314
+ ? `{#if dockNarrow}
2315
+ <div class="st-screen${screenClassSuffix(screen)}">
2316
+ ${body}
2317
+ </div>
2318
+ {:else}
2319
+ <div class="st-dock">
2320
+ <SvDockManager bind:workspace={dockWorkspace}${panePersist ? ' onChange={(w) => saveDock(w)}' : ''}${paneAttrStr}>
2321
+ {#snippet pane(p)}
2322
+ ${dockPanes}
2323
+ {/snippet}
2324
+ </SvDockManager>
2325
+ </div>
2292
2326
  {/if}`
2293
2327
  : isCanvas
2294
- ? `<div class="st-canvas${screenClassSuffix(screen)}">
2295
- ${canvasBody}
2328
+ ? `<div class="st-canvas${screenClassSuffix(screen)}">
2329
+ ${canvasBody}
2296
2330
  </div>`
2297
- : `<div class="${screenLayoutOf(screen) === 'stack' ? 'st-stack' : 'st-screen'}${screenClassSuffix(screen)}">
2298
- ${body}
2331
+ : `<div class="${screenLayoutOf(screen) === 'stack' ? 'st-stack' : 'st-screen'}${screenClassSuffix(screen)}">
2332
+ ${body}
2299
2333
  </div>`;
2300
2334
  const modal = wantsForm
2301
2335
  ? `\n\n{#if editing !== undefined}\n <SvGridEditPanel schema={${n.schemaVar}} row={editing}${relationFields.length ? ' {lookups}' : ''} presentation="${formPres}"${formLayoutProps} persistKey="${screen.route}" onSubmit={save} onCancel={() => (editing = undefined)} />\n{/if}`
@@ -2316,37 +2350,37 @@ ${body}
2316
2350
  const title = i18nEnabled ? `{$t('screen.${screen.id}', ${JSON.stringify(screen.title)})}` : screen.title;
2317
2351
  // Surface a failed data load (silent empty grid otherwise) with a retry.
2318
2352
  const errorBanner = needsController
2319
- ? `\n{#if view.error}
2320
- <div class="st-error" role="alert">
2321
- <span>Couldn't load data. {view.error instanceof Error ? view.error.message : String(view.error)}</span>
2322
- <button type="button" class="st-btn" onclick={() => controller.refresh()}>Retry</button>
2323
- </div>
2353
+ ? `\n{#if view.error}
2354
+ <div class="st-error" role="alert">
2355
+ <span>Couldn't load data. {view.error instanceof Error ? view.error.message : String(view.error)}</span>
2356
+ <button type="button" class="st-btn" onclick={() => controller.refresh()}>Retry</button>
2357
+ </div>
2324
2358
  {/if}\n`
2325
2359
  : '';
2326
2360
  return {
2327
2361
  path: `src/routes/${screen.route}/+page.svelte`,
2328
2362
  description: `${screen.title} screen (${blocks.map((b) => b.config.kind).join(', ') || 'empty'}).`,
2329
- contents: `<script lang="ts">
2330
- ${gridImports}${entImport}${connImport}${handleImport}${accessImport}${i18nImport}${gotoImport}${codeImport}${pageImport}${ssrData ? "import type { PageProps } from './$types'\n " : ''}import { ${schemaVarImports.join(', ')}, ${typeImports.map((t) => `type ${t}`).join(', ')} } from '$lib/schemas'
2331
- ${dataImports.length ? ` import { ${dataImports.join(', ')} } from '$lib/data'\n` : ''}
2332
- ${ssrData ? 'let { data }: PageProps = $props()\n\n ' : ''}${parts.join('\n\n ')}
2333
- </script>
2334
-
2335
- <h1 class="st__title">${title}</h1>
2336
- ${errorBanner}
2337
- ${toolbar}${screenBody}${modal}${actionSnippets.length ? '\n\n' + actionSnippets.join('\n\n') : ''}
2363
+ contents: `<script lang="ts">
2364
+ ${gridImports}${entImport}${connImport}${handleImport}${accessImport}${i18nImport}${gotoImport}${codeImport}${pageImport}${ssrData ? "import type { PageProps } from './$types'\n " : ''}import { ${schemaVarImports.join(', ')}, ${typeImports.map((t) => `type ${t}`).join(', ')} } from '$lib/schemas'
2365
+ ${dataImports.length ? ` import { ${dataImports.join(', ')} } from '$lib/data'\n` : ''}
2366
+ ${ssrData ? 'let { data }: PageProps = $props()\n\n ' : ''}${parts.join('\n\n ')}
2367
+ </script>
2368
+
2369
+ <h1 class="st__title">${title}</h1>
2370
+ ${errorBanner}
2371
+ ${toolbar}${screenBody}${modal}${actionSnippets.length ? '\n\n' + actionSnippets.join('\n\n') : ''}
2338
2372
  ${(() => {
2339
- const kpiCss = has(blocks, 'kpi') || has(blocks, 'gauge') || has(blocks, 'tree') ? ` .kpi { position: relative; display: flex; flex-direction: column; gap: 6px; padding: 16px 18px; background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); overflow: hidden; }
2340
- .kpi__head { display: flex; align-items: center; justify-content: space-between; }
2341
- .kpi__label { font-size: 12.5px; font-weight: 600; color: var(--sg-muted, #64748b); text-transform: uppercase; letter-spacing: 0.03em; }
2342
- .kpi__value { font-size: 28px; font-weight: 750; line-height: 1.1; color: var(--sg-fg, #0f172a); }
2343
- .kpi__delta { align-self: flex-start; display: inline-flex; align-items: center; gap: 3px; padding: 2px 8px; border-radius: 999px; font-size: 11.5px; font-weight: 700; background: color-mix(in srgb, var(--sg-muted, #64748b) 14%, transparent); color: var(--sg-muted, #64748b); }
2344
- .kpi__delta.is-up { background: color-mix(in srgb, #16a34a 15%, transparent); color: #16a34a; }
2345
- .kpi__delta.is-down { background: color-mix(in srgb, #dc2626 15%, transparent); color: #dc2626; }
2346
- .kpi__spark { width: 100%; height: 30px; margin-top: 2px; color: var(--sg-accent, #4f46e5); opacity: 0.85; }
2347
- .gaugecard { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 16px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; }
2348
- .gaugecard .kpi__label { align-self: flex-start; }
2349
- .treecard { padding: 12px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; }
2373
+ const kpiCss = has(blocks, 'kpi') || has(blocks, 'gauge') || has(blocks, 'tree') ? ` .kpi { position: relative; display: flex; flex-direction: column; gap: 6px; padding: 16px 18px; background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); overflow: hidden; }
2374
+ .kpi__head { display: flex; align-items: center; justify-content: space-between; }
2375
+ .kpi__label { font-size: 12.5px; font-weight: 600; color: var(--sg-muted, #64748b); text-transform: uppercase; letter-spacing: 0.03em; }
2376
+ .kpi__value { font-size: 28px; font-weight: 750; line-height: 1.1; color: var(--sg-fg, #0f172a); }
2377
+ .kpi__delta { align-self: flex-start; display: inline-flex; align-items: center; gap: 3px; padding: 2px 8px; border-radius: 999px; font-size: 11.5px; font-weight: 700; background: color-mix(in srgb, var(--sg-muted, #64748b) 14%, transparent); color: var(--sg-muted, #64748b); }
2378
+ .kpi__delta.is-up { background: color-mix(in srgb, #16a34a 15%, transparent); color: #16a34a; }
2379
+ .kpi__delta.is-down { background: color-mix(in srgb, #dc2626 15%, transparent); color: #dc2626; }
2380
+ .kpi__spark { width: 100%; height: 30px; margin-top: 2px; color: var(--sg-accent, #4f46e5); opacity: 0.85; }
2381
+ .gaugecard { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 16px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; }
2382
+ .gaugecard .kpi__label { align-self: flex-start; }
2383
+ .treecard { padding: 12px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; }
2350
2384
  ` : '';
2351
2385
  const css = kpiCss + screenLayoutStyle(screen);
2352
2386
  return css ? `\n<style>\n${css}</style>\n` : '';
@@ -2387,19 +2421,19 @@ function ssrReadServerFile(schema, screen, sourceKind, accessEnabled, screenIds,
2387
2421
  return {
2388
2422
  path: `src/routes/${screen.route}/+page.server.ts`,
2389
2423
  description: `${screen.title} - SSR load (full dataset${children.length ? ' + child collections' : ''}).`,
2390
- contents: `import type { PageServerLoad } from './$types'
2391
- import type { ServerRequest } from '@svgrid/grid'
2392
- ${isSql ? "import { createKitDataSource } from '@svgrid/enterprise'\n" : ''}${rbac ? "import { error } from '@sveltejs/kit'\nimport { authorizeAction, getServerRole } from '$lib/access'\n" : ''}${memImports.length ? `import { ${memImports.join(', ')} } from '$lib/data'\n` : ''}
2393
- // This screen renders on the server: real SSR HTML, not just a server-side
2394
- // load. Stated explicitly so it holds whichever way the root layout is set.
2395
- export const ssr = true
2396
- ${rbac ? `\nconst SCREEN_IDS = ${JSON.stringify(screenIds)}\n` : ''}
2397
- const PAGE: ServerRequest = { startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} }
2398
-
2399
- export const load: PageServerLoad = async (${isSql || rbac ? `{ ${[isSql ? 'fetch' : '', rbac ? 'locals' : ''].filter(Boolean).join(', ')} }` : ''}) => {
2400
- ${rbac ? ` if (!authorizeAction(getServerRole({ locals }), 'read', SCREEN_IDS)) throw error(403, 'Not allowed')\n` : ''} const rows = (await ${srcExpr(schema)}.getRows(PAGE)).rows
2401
- ${childLoads ? childLoads + '\n' : ''} return { rows${children.map((c) => `, ${mdChildVar(c.name)}`).join('')} }
2402
- }
2424
+ contents: `import type { PageServerLoad } from './$types'
2425
+ import type { ServerRequest } from '@svgrid/grid'
2426
+ ${isSql ? "import { createKitDataSource } from '@svgrid/enterprise'\n" : ''}${rbac ? "import { error } from '@sveltejs/kit'\nimport { authorizeAction, getServerRole } from '$lib/access'\n" : ''}${memImports.length ? `import { ${memImports.join(', ')} } from '$lib/data'\n` : ''}
2427
+ // This screen renders on the server: real SSR HTML, not just a server-side
2428
+ // load. Stated explicitly so it holds whichever way the root layout is set.
2429
+ export const ssr = true
2430
+ ${rbac ? `\nconst SCREEN_IDS = ${JSON.stringify(screenIds)}\n` : ''}
2431
+ const PAGE: ServerRequest = { startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} }
2432
+
2433
+ export const load: PageServerLoad = async (${isSql || rbac ? `{ ${[isSql ? 'fetch' : '', rbac ? 'locals' : ''].filter(Boolean).join(', ')} }` : ''}) => {
2434
+ ${rbac ? ` if (!authorizeAction(getServerRole({ locals }), 'read', SCREEN_IDS)) throw error(403, 'Not allowed')\n` : ''} const rows = (await ${srcExpr(schema)}.getRows(PAGE)).rows
2435
+ ${childLoads ? childLoads + '\n' : ''} return { rows${children.map((c) => `, ${mdChildVar(c.name)}`).join('')} }
2436
+ }
2403
2437
  `,
2404
2438
  };
2405
2439
  }
@@ -2414,34 +2448,34 @@ ${childLoads ? childLoads + '\n' : ''} return { rows${children.map((c) => `, ${
2414
2448
  // sort/pagination back into the URL. Server-side validation runs in the actions.
2415
2449
  // ---------------------------------------------------------------------------
2416
2450
  /** URL search params -> a data-source request. Shared by every SSR route's load. */
2417
- const SSR_QUERY_HELPER = `// Turn a page URL's search params into a data-source request (sort / filter /
2418
- // page / size). The grid drives these params via goto(), so the server re-runs
2419
- // load() - which makes every list view bookmarkable and functional with no JS.
2420
- import type { ServerRequest } from '@svgrid/grid'
2421
-
2422
- export function planFromSearchParams(url: URL, defaultPageSize = 25): ServerRequest {
2423
- const sp = url.searchParams
2424
- const pageIndex = Math.max(0, Number.parseInt(sp.get('page') ?? '', 10) || 0)
2425
- const pageSize = Math.min(200, Math.max(1, Number.parseInt(sp.get('size') ?? '', 10) || defaultPageSize))
2426
- const sortModel = (sp.get('sort') ?? '')
2427
- .split(',')
2428
- .filter(Boolean)
2429
- .map((token) => {
2430
- const [id, dir] = token.split(':')
2431
- return { id: id!, desc: dir === 'desc' }
2432
- })
2433
- const columns: Record<string, { operator: 'contains'; value: string }> = {}
2434
- for (const [k, v] of sp) if (k.startsWith('f_') && v) columns[k.slice(2)] = { operator: 'contains', value: v }
2435
- const global = sp.get('q') ?? ''
2436
- return {
2437
- startRow: pageIndex * pageSize,
2438
- endRow: pageIndex * pageSize + pageSize,
2439
- pageIndex,
2440
- pageSize,
2441
- sortModel,
2442
- filterModel: { ...(global ? { global } : {}), columns },
2443
- }
2444
- }
2451
+ const SSR_QUERY_HELPER = `// Turn a page URL's search params into a data-source request (sort / filter /
2452
+ // page / size). The grid drives these params via goto(), so the server re-runs
2453
+ // load() - which makes every list view bookmarkable and functional with no JS.
2454
+ import type { ServerRequest } from '@svgrid/grid'
2455
+
2456
+ export function planFromSearchParams(url: URL, defaultPageSize = 25): ServerRequest {
2457
+ const sp = url.searchParams
2458
+ const pageIndex = Math.max(0, Number.parseInt(sp.get('page') ?? '', 10) || 0)
2459
+ const pageSize = Math.min(200, Math.max(1, Number.parseInt(sp.get('size') ?? '', 10) || defaultPageSize))
2460
+ const sortModel = (sp.get('sort') ?? '')
2461
+ .split(',')
2462
+ .filter(Boolean)
2463
+ .map((token) => {
2464
+ const [id, dir] = token.split(':')
2465
+ return { id: id!, desc: dir === 'desc' }
2466
+ })
2467
+ const columns: Record<string, { operator: 'contains'; value: string }> = {}
2468
+ for (const [k, v] of sp) if (k.startsWith('f_') && v) columns[k.slice(2)] = { operator: 'contains', value: v }
2469
+ const global = sp.get('q') ?? ''
2470
+ return {
2471
+ startRow: pageIndex * pageSize,
2472
+ endRow: pageIndex * pageSize + pageSize,
2473
+ pageIndex,
2474
+ pageSize,
2475
+ sortModel,
2476
+ filterModel: { ...(global ? { global } : {}), columns },
2477
+ }
2478
+ }
2445
2479
  `;
2446
2480
  function ssrQueryHelperFile() {
2447
2481
  return { path: 'src/lib/server/query.ts', description: 'SSR: URL search params -> data-source request (sort/filter/page).', contents: SSR_QUERY_HELPER };
@@ -2557,60 +2591,60 @@ function emitSsrGridScreen(schema, screen, sourceKind, accessEnabled, screenIds,
2557
2591
  const localsArg = rbac ? ', locals' : '';
2558
2592
  const readGuard = rbac ? ` if (!authorizeAction(getServerRole({ locals }), 'read', SCREEN_IDS)) throw error(403, 'Not allowed')\n` : '';
2559
2593
  const writeGuard = (action) => (rbac ? ` if (!authorizeAction(getServerRole({ locals }), '${action}', SCREEN_IDS)) return fail(403, { error: 'Not allowed' })\n` : '');
2560
- const server = `import type { Actions, PageServerLoad } from './$types'
2561
- import { fail${rbac ? ', error' : ''} } from '@sveltejs/kit'
2562
- import { ${enterpriseImports} } from '@svgrid/enterprise'
2563
- ${rbac ? "import { authorizeAction, getServerRole } from '../lib/access'\n" : ''}${sourceImport}${schemaImport}
2564
- import { planFromSearchParams } from '../lib/server/query'
2565
-
2566
- // This screen renders on the server: real SSR HTML, not just a server-side
2567
- // load. Stated explicitly so it holds whichever way the root layout is set.
2568
- export const ssr = true
2569
- ${idConst}${rbac ? `\nconst SCREEN_IDS = ${JSON.stringify(screenIds)}` : ''}
2570
- const FIELD_TYPES: Record<string, 'text' | 'number' | 'boolean'> = ${fieldTypesLit}
2571
- ${srcHelper}
2572
- /** Read a submitted form into a typed partial row. Booleans come from checkbox
2573
- * presence; numbers are coerced; empty values are dropped so they don't clobber. */
2574
- function formToValues(fd: FormData): Record<string, unknown> {
2575
- const values: Record<string, unknown> = {}
2576
- for (const [field, type] of Object.entries(FIELD_TYPES)) {
2577
- if (type === 'boolean') { values[field] = fd.get(field) != null; continue }
2578
- const raw = fd.get(field)
2579
- if (raw == null || raw === '') continue
2580
- values[field] = type === 'number' ? Number(raw) : String(raw)
2581
- }
2582
- return values
2583
- }
2584
-
2585
- export const load: PageServerLoad = async ({ url${fetchArg}${localsArg} }) => {
2586
- ${readGuard} const plan = planFromSearchParams(url, ${pageSize})
2587
- const { rows, rowCount } = await ${src}.getRows(plan)
2588
- ${relPrefetch ? relPrefetch + '\n' : ''} return { rows, total: rowCount, page: plan.pageIndex, size: plan.pageSize, sort: plan.sortModel${relReturn} }
2589
- }
2590
-
2591
- export const actions: Actions = {
2592
- create: async ({ request${fetchArg}${localsArg} }) => {
2593
- ${writeGuard('create')} const values = ${readValues('await request.formData()')}
2594
- const errors = await validateAll(${n.schemaVar}, values)
2595
- if (Object.keys(errors).length) return fail(422, { errors, values })
2596
- ${createCall}
2597
- return { ok: true }
2598
- },
2599
- update: async ({ request${fetchArg}${localsArg} }) => {
2600
- const fd = await request.formData()
2601
- ${writeGuard('update')} const id = String(fd.get('__id') ?? '')
2602
- const values = ${readValues('fd')}
2603
- const errors = await validateAll(${n.schemaVar}, values)
2604
- if (Object.keys(errors).length) return fail(422, { errors, values })
2605
- await ${src}.updateRow(id, values)
2606
- return { ok: true }
2607
- },
2608
- delete: async ({ request${fetchArg}${localsArg} }) => {
2609
- ${writeGuard('delete')} const fd = await request.formData()
2610
- await ${src}.deleteRow(String(fd.get('__id') ?? ''))
2611
- return { ok: true }
2612
- },
2613
- }
2594
+ const server = `import type { Actions, PageServerLoad } from './$types'
2595
+ import { fail${rbac ? ', error' : ''} } from '@sveltejs/kit'
2596
+ import { ${enterpriseImports} } from '@svgrid/enterprise'
2597
+ ${rbac ? "import { authorizeAction, getServerRole } from '../lib/access'\n" : ''}${sourceImport}${schemaImport}
2598
+ import { planFromSearchParams } from '../lib/server/query'
2599
+
2600
+ // This screen renders on the server: real SSR HTML, not just a server-side
2601
+ // load. Stated explicitly so it holds whichever way the root layout is set.
2602
+ export const ssr = true
2603
+ ${idConst}${rbac ? `\nconst SCREEN_IDS = ${JSON.stringify(screenIds)}` : ''}
2604
+ const FIELD_TYPES: Record<string, 'text' | 'number' | 'boolean'> = ${fieldTypesLit}
2605
+ ${srcHelper}
2606
+ /** Read a submitted form into a typed partial row. Booleans come from checkbox
2607
+ * presence; numbers are coerced; empty values are dropped so they don't clobber. */
2608
+ function formToValues(fd: FormData): Record<string, unknown> {
2609
+ const values: Record<string, unknown> = {}
2610
+ for (const [field, type] of Object.entries(FIELD_TYPES)) {
2611
+ if (type === 'boolean') { values[field] = fd.get(field) != null; continue }
2612
+ const raw = fd.get(field)
2613
+ if (raw == null || raw === '') continue
2614
+ values[field] = type === 'number' ? Number(raw) : String(raw)
2615
+ }
2616
+ return values
2617
+ }
2618
+
2619
+ export const load: PageServerLoad = async ({ url${fetchArg}${localsArg} }) => {
2620
+ ${readGuard} const plan = planFromSearchParams(url, ${pageSize})
2621
+ const { rows, rowCount } = await ${src}.getRows(plan)
2622
+ ${relPrefetch ? relPrefetch + '\n' : ''} return { rows, total: rowCount, page: plan.pageIndex, size: plan.pageSize, sort: plan.sortModel${relReturn} }
2623
+ }
2624
+
2625
+ export const actions: Actions = {
2626
+ create: async ({ request${fetchArg}${localsArg} }) => {
2627
+ ${writeGuard('create')} const values = ${readValues('await request.formData()')}
2628
+ const errors = await validateAll(${n.schemaVar}, values)
2629
+ if (Object.keys(errors).length) return fail(422, { errors, values })
2630
+ ${createCall}
2631
+ return { ok: true }
2632
+ },
2633
+ update: async ({ request${fetchArg}${localsArg} }) => {
2634
+ const fd = await request.formData()
2635
+ ${writeGuard('update')} const id = String(fd.get('__id') ?? '')
2636
+ const values = ${readValues('fd')}
2637
+ const errors = await validateAll(${n.schemaVar}, values)
2638
+ if (Object.keys(errors).length) return fail(422, { errors, values })
2639
+ await ${src}.updateRow(id, values)
2640
+ return { ok: true }
2641
+ },
2642
+ delete: async ({ request${fetchArg}${localsArg} }) => {
2643
+ ${writeGuard('delete')} const fd = await request.formData()
2644
+ await ${src}.deleteRow(String(fd.get('__id') ?? ''))
2645
+ return { ok: true }
2646
+ },
2647
+ }
2614
2648
  `;
2615
2649
  // The facet panel becomes a plain GET form: each control is named for the
2616
2650
  // param `planFromSearchParams` reads, so submitting navigates to a filtered
@@ -2658,15 +2692,15 @@ ${writeGuard('delete')} const fd = await request.formData()
2658
2692
  return ` <label class="sk-facet">\n <span>${htmlEsc(f.label ?? f.field)}</span>\n ${control}\n </label>`;
2659
2693
  })
2660
2694
  .join('\n');
2661
- return `
2662
- <form method="GET" class="sk-facets">
2663
- <input type="hidden" name="sort" value={page.url.searchParams.get('sort') ?? ''} />
2664
- ${controls}
2665
- <div class="sk-facet-actions">
2666
- <button type="submit">Filter</button>
2667
- <a href={page.url.pathname}>Clear</a>
2668
- </div>
2669
- </form>
2695
+ return `
2696
+ <form method="GET" class="sk-facets">
2697
+ <input type="hidden" name="sort" value={page.url.searchParams.get('sort') ?? ''} />
2698
+ ${controls}
2699
+ <div class="sk-facet-actions">
2700
+ <button type="submit">Filter</button>
2701
+ <a href={page.url.pathname}>Clear</a>
2702
+ </div>
2703
+ </form>
2670
2704
  `;
2671
2705
  })();
2672
2706
  const facetCss = !facetForm
@@ -2719,10 +2753,10 @@ ${controls}
2719
2753
  input = `<input type="${t}" name=${key} value={isCreate ? '' : (${row}[${key}] ?? '')}${req}${attrs(f)} />`;
2720
2754
  }
2721
2755
  const hint = f.input?.help;
2722
- return ` <label class="sk-field${f.input?.span === 2 ? ' sk-field--wide' : ''}">
2723
- <span>${htmlEsc(f.label ?? f.field)}${f.required ? ' *' : ''}</span>
2724
- ${input}
2725
- ${hint ? ` {#if !form?.errors?.[${key}]}<small class="sk-hint">${htmlEsc(hint)}</small>{/if}\n` : ''} {#if form?.errors?.[${key}]}<em class="sk-err">{form.errors[${key}]}</em>{/if}
2756
+ return ` <label class="sk-field${f.input?.span === 2 ? ' sk-field--wide' : ''}">
2757
+ <span>${htmlEsc(f.label ?? f.field)}${f.required ? ' *' : ''}</span>
2758
+ ${input}
2759
+ ${hint ? ` {#if !form?.errors?.[${key}]}<small class="sk-hint">${htmlEsc(hint)}</small>{/if}\n` : ''} {#if form?.errors?.[${key}]}<em class="sk-err">{form.errors[${key}]}</em>{/if}
2726
2760
  </label>`;
2727
2761
  });
2728
2762
  // Group the inputs the way the schema's form layout says, so the server-
@@ -2762,145 +2796,145 @@ ${hint ? ` {#if !form?.errors?.[${key}]}<small class="sk-hint">${htmlEs
2762
2796
  const open = g.collapsed
2763
2797
  ? `{${JSON.stringify(g.names)}.some((f) => form?.errors?.[f])}`
2764
2798
  : '{true}';
2765
- return ` <details class="sk-group sk-group--fold" style="--sk-cols: ${g.columns ?? formCols}" open=${open}>
2766
- <summary>${htmlEsc(g.title)}</summary>
2767
- ${inner}
2799
+ return ` <details class="sk-group sk-group--fold" style="--sk-cols: ${g.columns ?? formCols}" open=${open}>
2800
+ <summary>${htmlEsc(g.title)}</summary>
2801
+ ${inner}
2768
2802
  </details>`;
2769
2803
  }
2770
- return ` <fieldset class="sk-group" style="--sk-cols: ${g.columns ?? formCols}">
2771
- ${g.title ? ` <legend>${htmlEsc(g.title)}</legend>\n` : ''}${inner}
2804
+ return ` <fieldset class="sk-group" style="--sk-cols: ${g.columns ?? formCols}">
2805
+ ${g.title ? ` <legend>${htmlEsc(g.title)}</legend>\n` : ''}${inner}
2772
2806
  </fieldset>`;
2773
2807
  })
2774
2808
  .join('\n')
2775
2809
  : ` <div class="sk-group" style="--sk-cols: ${formCols}">\n${fieldBlocks.join('\n')}\n </div>`;
2776
- const page = `<script lang="ts">
2777
- import { SvGrid, renderSnippet, type ColumnDef, type CellContext } from '@svgrid/grid'
2778
- import { schemaToColumns } from '@svgrid/enterprise'
2779
- import { goto } from '$app/navigation'
2780
- import { page } from '$app/state'
2781
- import { enhance } from '$app/forms'
2782
- import type { SubmitFunction } from '@sveltejs/kit'
2783
- import { ${n.schemaVar}, type ${n.type} } from '$lib/schemas'
2784
- import type { PageProps } from './$types'
2785
-
2786
- let { data, form }: PageProps = $props()
2787
-
2788
- const ID_FIELD = ${jsStr(idField)}
2789
- const TITLE = ${jsStr(screen.title)}
2790
- const NEW_LABEL = ${jsStr('New ' + n.label)}
2791
-
2792
- // Grid columns from the schema + a row-actions column (Edit / Delete).
2793
- const columns: ColumnDef<Record<string, never>, ${n.type}>[] = [
2794
- ...(schemaToColumns(${n.schemaVar}) as ColumnDef<Record<string, never>, ${n.type}>[]),
2795
- { id: '__actions', header: '', sortable: false, cell: (ctx: CellContext<${n.type}>) => renderSnippet(rowActions, { row: ctx.row.original }) },
2796
- ]
2797
-
2798
- // null = no editor; 'create' = new row; a row = editing that row.
2799
- let editing = $state<'create' | ${n.type} | null>(null)
2800
- const isCreate = $derived(editing === 'create')
2801
-
2802
- // Sort / paginate by writing to the URL - load() re-runs on the server.
2803
- function setParams(patch: Record<string, string | null>) {
2804
- const sp = new URLSearchParams(page.url.searchParams)
2805
- for (const [k, v] of Object.entries(patch)) { if (v == null) sp.delete(k); else sp.set(k, v) }
2806
- const q = sp.toString()
2807
- void goto(q ? \`?\${q}\` : page.url.pathname, { keepFocus: true, noScroll: true })
2808
- }
2809
- ${wantsFilter ? `
2810
- // Filter via the URL (q = global search, f_<col> = per-column). The server's
2811
- // planFromSearchParams reads these, so load() re-filters; reset to the first page.
2812
- function applyFilters(f: { global: string; columns: Array<{ id: string; value: string }> }) {
2813
- const sp = new URLSearchParams(page.url.searchParams)
2814
- for (const k of [...sp.keys()]) if (k === 'q' || k.startsWith('f_')) sp.delete(k)
2815
- if (f.global) sp.set('q', f.global)
2816
- for (const c of f.columns) if (c.value) sp.set('f_' + c.id, c.value)
2817
- sp.delete('page')
2818
- const q = sp.toString()
2819
- void goto(q ? \`?\${q}\` : page.url.pathname, { keepFocus: true, noScroll: true })
2820
- }
2821
- ` : ''}
2822
- // Progressive enhancement: post to the action, keep typed values on error,
2823
- // close the editor on success (load re-runs automatically, refreshing the grid).
2824
- const onSubmit: SubmitFunction = () => async ({ result, update }) => {
2825
- await update({ reset: false })
2826
- if (result.type === 'success') editing = null
2827
- }
2828
- </script>
2829
-
2830
- {#snippet rowActions({ row }: { row: ${n.type} })}
2831
- <div class="sk-rowact">
2832
- <button type="button" class="sk-link" onclick={() => (editing = row)}>Edit</button>
2833
- <form method="POST" action="?/delete" use:enhance={onSubmit} style="display:contents">
2834
- <input type="hidden" name="__id" value={(row as Record<string, unknown>)[ID_FIELD] as string} />
2835
- <button type="submit" class="sk-link sk-danger">Delete</button>
2836
- </form>
2837
- </div>
2838
- {/snippet}
2839
-
2840
- <header class="sk-head">
2841
- <h1>{TITLE}</h1>
2842
- <button type="button" class="sk-btn sk-btn--primary" onclick={() => (editing = 'create')}>{NEW_LABEL}</button>
2843
- </header>
2844
- ${facetForm}
2845
- <SvGrid
2846
- data={data.rows}
2847
- {columns}
2848
- externalSort
2849
- initialSorting={data.sort}
2850
- externalPagination${wantsFilter ? '\n filterable\n externalFilter\n onFiltersChange={applyFilters}' : ''}
2851
- rowCount={data.total}
2852
- pageIndex={data.page}
2853
- pageSize={data.size}
2854
- onSortingChange={(s) => setParams({ sort: s.map((x) => \`\${x.id}:\${x.desc ? 'desc' : 'asc'}\`).join(',') || null, page: null })}
2855
- onPaginationChange={(p) => setParams({ page: String(p.pageIndex), size: String(p.pageSize) })}
2856
- />
2857
-
2858
- {#if editing}
2859
- <div class="sk-overlay">
2860
- <form method="POST" action={isCreate ? '?/create' : '?/update'} class="sk-form" use:enhance={onSubmit}>
2861
- <h2>{isCreate ? NEW_LABEL : 'Edit ${htmlEsc(n.label)}'}</h2>
2862
- {#if !isCreate}<input type="hidden" name="__id" value={${row}[ID_FIELD] as string} />{/if}
2863
- ${fieldsMarkup}
2864
- <div class="sk-form__actions">
2865
- <button type="button" class="sk-btn" onclick={() => (editing = null)}>Cancel</button>
2866
- <button type="submit" class="sk-btn sk-btn--primary">Save</button>
2867
- </div>
2868
- </form>
2869
- </div>
2870
- {/if}
2871
-
2872
- <style>
2873
- .sk-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
2874
- .sk-head h1 { margin: 0; font-size: 20px; }
2875
- .sk-btn { font: inherit; padding: 7px 14px; border-radius: 8px; border: 1px solid var(--sg-border, #cbd5e1); background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); cursor: pointer; }
2876
- .sk-btn--primary { background: var(--sg-accent, #4f46e5); border-color: var(--sg-accent, #4f46e5); color: #fff; }
2877
- ${facetCss} .sk-rowact { display: flex; gap: 10px; }
2878
- .sk-link { background: none; border: none; padding: 0; font: inherit; color: var(--sg-accent, #4f46e5); cursor: pointer; }
2879
- .sk-danger { color: #dc2626; }
2880
- .sk-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.4); display: grid; place-items: center; z-index: 50; }
2881
- .sk-form { width: min(480px, 92vw); max-height: 90vh; overflow: auto; background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); border-radius: 12px; padding: 22px; display: flex; flex-direction: column; gap: 12px; box-shadow: 0 24px 60px -20px rgba(15, 23, 42, 0.5); }
2882
- .sk-form h2 { margin: 0 0 4px; font-size: 16px; }
2883
- .sk-group { display: grid; grid-template-columns: repeat(var(--sk-cols, 1), minmax(0, 1fr)); gap: 12px; border: 0; padding: 0; margin: 0; min-width: 0; }
2884
- .sk-group + .sk-group { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--sg-border, #e2e8f0); }
2885
- .sk-group legend { grid-column: 1 / -1; padding: 0; font-size: 12px; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase; color: var(--sg-muted, #64748b); }
2886
- .sk-group__desc { grid-column: 1 / -1; margin: 0; font-size: 12px; color: var(--sg-muted, #64748b); }
2887
- /* A foldable group is a native <details>, so it works with JavaScript off.
2888
- display:grid on the element itself would lay the <summary> out as a grid
2889
- item and break the disclosure, so the grid moves to [open] children. */
2890
- .sk-group--fold { display: block; }
2891
- .sk-group--fold > summary { grid-column: 1 / -1; margin-bottom: 12px; font-size: 13.5px; font-weight: 650; color: var(--sg-fg, #0f172a); cursor: pointer; list-style-position: inside; }
2892
- .sk-group--fold[open] { display: grid; }
2893
- .sk-group--fold[open] > summary { margin-bottom: 0; }
2894
- .sk-field--wide { grid-column: 1 / -1; }
2895
- .sk-hint { color: var(--sg-muted, #64748b); font-size: 11.5px; }
2896
- @media (max-width: 560px) { .sk-group { grid-template-columns: 1fr; } }
2897
- .sk-field { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
2898
- .sk-field span { color: var(--sg-muted, #64748b); }
2899
- .sk-field input, .sk-field select { font: inherit; padding: 7px 9px; border-radius: 8px; border: 1px solid var(--sg-border, #cbd5e1); background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); }
2900
- .sk-field input[type='checkbox'] { align-self: flex-start; width: auto; }
2901
- .sk-err { color: #dc2626; font-size: 12px; font-style: normal; }
2902
- .sk-form__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 6px; }
2903
- </style>
2810
+ const page = `<script lang="ts">
2811
+ import { SvGrid, renderSnippet, type ColumnDef, type CellContext } from '@svgrid/grid'
2812
+ import { schemaToColumns } from '@svgrid/enterprise'
2813
+ import { goto } from '$app/navigation'
2814
+ import { page } from '$app/state'
2815
+ import { enhance } from '$app/forms'
2816
+ import type { SubmitFunction } from '@sveltejs/kit'
2817
+ import { ${n.schemaVar}, type ${n.type} } from '$lib/schemas'
2818
+ import type { PageProps } from './$types'
2819
+
2820
+ let { data, form }: PageProps = $props()
2821
+
2822
+ const ID_FIELD = ${jsStr(idField)}
2823
+ const TITLE = ${jsStr(screen.title)}
2824
+ const NEW_LABEL = ${jsStr('New ' + n.label)}
2825
+
2826
+ // Grid columns from the schema + a row-actions column (Edit / Delete).
2827
+ const columns: ColumnDef<Record<string, never>, ${n.type}>[] = [
2828
+ ...(schemaToColumns(${n.schemaVar}) as ColumnDef<Record<string, never>, ${n.type}>[]),
2829
+ { id: '__actions', header: '', sortable: false, cell: (ctx: CellContext<${n.type}>) => renderSnippet(rowActions, { row: ctx.row.original }) },
2830
+ ]
2831
+
2832
+ // null = no editor; 'create' = new row; a row = editing that row.
2833
+ let editing = $state<'create' | ${n.type} | null>(null)
2834
+ const isCreate = $derived(editing === 'create')
2835
+
2836
+ // Sort / paginate by writing to the URL - load() re-runs on the server.
2837
+ function setParams(patch: Record<string, string | null>) {
2838
+ const sp = new URLSearchParams(page.url.searchParams)
2839
+ for (const [k, v] of Object.entries(patch)) { if (v == null) sp.delete(k); else sp.set(k, v) }
2840
+ const q = sp.toString()
2841
+ void goto(q ? \`?\${q}\` : page.url.pathname, { keepFocus: true, noScroll: true })
2842
+ }
2843
+ ${wantsFilter ? `
2844
+ // Filter via the URL (q = global search, f_<col> = per-column). The server's
2845
+ // planFromSearchParams reads these, so load() re-filters; reset to the first page.
2846
+ function applyFilters(f: { global: string; columns: Array<{ id: string; value: string }> }) {
2847
+ const sp = new URLSearchParams(page.url.searchParams)
2848
+ for (const k of [...sp.keys()]) if (k === 'q' || k.startsWith('f_')) sp.delete(k)
2849
+ if (f.global) sp.set('q', f.global)
2850
+ for (const c of f.columns) if (c.value) sp.set('f_' + c.id, c.value)
2851
+ sp.delete('page')
2852
+ const q = sp.toString()
2853
+ void goto(q ? \`?\${q}\` : page.url.pathname, { keepFocus: true, noScroll: true })
2854
+ }
2855
+ ` : ''}
2856
+ // Progressive enhancement: post to the action, keep typed values on error,
2857
+ // close the editor on success (load re-runs automatically, refreshing the grid).
2858
+ const onSubmit: SubmitFunction = () => async ({ result, update }) => {
2859
+ await update({ reset: false })
2860
+ if (result.type === 'success') editing = null
2861
+ }
2862
+ </script>
2863
+
2864
+ {#snippet rowActions({ row }: { row: ${n.type} })}
2865
+ <div class="sk-rowact">
2866
+ <button type="button" class="sk-link" onclick={() => (editing = row)}>Edit</button>
2867
+ <form method="POST" action="?/delete" use:enhance={onSubmit} style="display:contents">
2868
+ <input type="hidden" name="__id" value={(row as Record<string, unknown>)[ID_FIELD] as string} />
2869
+ <button type="submit" class="sk-link sk-danger">Delete</button>
2870
+ </form>
2871
+ </div>
2872
+ {/snippet}
2873
+
2874
+ <header class="sk-head">
2875
+ <h1>{TITLE}</h1>
2876
+ <button type="button" class="sk-btn sk-btn--primary" onclick={() => (editing = 'create')}>{NEW_LABEL}</button>
2877
+ </header>
2878
+ ${facetForm}
2879
+ <SvGrid
2880
+ data={data.rows}
2881
+ {columns}
2882
+ externalSort
2883
+ initialSorting={data.sort}
2884
+ externalPagination${wantsFilter ? '\n filterable\n externalFilter\n onFiltersChange={applyFilters}' : ''}
2885
+ rowCount={data.total}
2886
+ pageIndex={data.page}
2887
+ pageSize={data.size}
2888
+ onSortingChange={(s) => setParams({ sort: s.map((x) => \`\${x.id}:\${x.desc ? 'desc' : 'asc'}\`).join(',') || null, page: null })}
2889
+ onPaginationChange={(p) => setParams({ page: String(p.pageIndex), size: String(p.pageSize) })}
2890
+ />
2891
+
2892
+ {#if editing}
2893
+ <div class="sk-overlay">
2894
+ <form method="POST" action={isCreate ? '?/create' : '?/update'} class="sk-form" use:enhance={onSubmit}>
2895
+ <h2>{isCreate ? NEW_LABEL : 'Edit ${htmlEsc(n.label)}'}</h2>
2896
+ {#if !isCreate}<input type="hidden" name="__id" value={${row}[ID_FIELD] as string} />{/if}
2897
+ ${fieldsMarkup}
2898
+ <div class="sk-form__actions">
2899
+ <button type="button" class="sk-btn" onclick={() => (editing = null)}>Cancel</button>
2900
+ <button type="submit" class="sk-btn sk-btn--primary">Save</button>
2901
+ </div>
2902
+ </form>
2903
+ </div>
2904
+ {/if}
2905
+
2906
+ <style>
2907
+ .sk-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
2908
+ .sk-head h1 { margin: 0; font-size: 20px; }
2909
+ .sk-btn { font: inherit; padding: 7px 14px; border-radius: 8px; border: 1px solid var(--sg-border, #cbd5e1); background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); cursor: pointer; }
2910
+ .sk-btn--primary { background: var(--sg-accent, #4f46e5); border-color: var(--sg-accent, #4f46e5); color: #fff; }
2911
+ ${facetCss} .sk-rowact { display: flex; gap: 10px; }
2912
+ .sk-link { background: none; border: none; padding: 0; font: inherit; color: var(--sg-accent, #4f46e5); cursor: pointer; }
2913
+ .sk-danger { color: #dc2626; }
2914
+ .sk-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.4); display: grid; place-items: center; z-index: 50; }
2915
+ .sk-form { width: min(480px, 92vw); max-height: 90vh; overflow: auto; background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); border-radius: 12px; padding: 22px; display: flex; flex-direction: column; gap: 12px; box-shadow: 0 24px 60px -20px rgba(15, 23, 42, 0.5); }
2916
+ .sk-form h2 { margin: 0 0 4px; font-size: 16px; }
2917
+ .sk-group { display: grid; grid-template-columns: repeat(var(--sk-cols, 1), minmax(0, 1fr)); gap: 12px; border: 0; padding: 0; margin: 0; min-width: 0; }
2918
+ .sk-group + .sk-group { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--sg-border, #e2e8f0); }
2919
+ .sk-group legend { grid-column: 1 / -1; padding: 0; font-size: 12px; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase; color: var(--sg-muted, #64748b); }
2920
+ .sk-group__desc { grid-column: 1 / -1; margin: 0; font-size: 12px; color: var(--sg-muted, #64748b); }
2921
+ /* A foldable group is a native <details>, so it works with JavaScript off.
2922
+ display:grid on the element itself would lay the <summary> out as a grid
2923
+ item and break the disclosure, so the grid moves to [open] children. */
2924
+ .sk-group--fold { display: block; }
2925
+ .sk-group--fold > summary { grid-column: 1 / -1; margin-bottom: 12px; font-size: 13.5px; font-weight: 650; color: var(--sg-fg, #0f172a); cursor: pointer; list-style-position: inside; }
2926
+ .sk-group--fold[open] { display: grid; }
2927
+ .sk-group--fold[open] > summary { margin-bottom: 0; }
2928
+ .sk-field--wide { grid-column: 1 / -1; }
2929
+ .sk-hint { color: var(--sg-muted, #64748b); font-size: 11.5px; }
2930
+ @media (max-width: 560px) { .sk-group { grid-template-columns: 1fr; } }
2931
+ .sk-field { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
2932
+ .sk-field span { color: var(--sg-muted, #64748b); }
2933
+ .sk-field input, .sk-field select { font: inherit; padding: 7px 9px; border-radius: 8px; border: 1px solid var(--sg-border, #cbd5e1); background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); }
2934
+ .sk-field input[type='checkbox'] { align-self: flex-start; width: auto; }
2935
+ .sk-err { color: #dc2626; font-size: 12px; font-style: normal; }
2936
+ .sk-form__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 6px; }
2937
+ </style>
2904
2938
  `;
2905
2939
  return [
2906
2940
  { path: `src/routes/${screen.route}/+page.server.ts`, description: `SSR load + CRUD form actions for ${n.label}.`, contents: server },
@@ -3095,29 +3129,29 @@ function i18nModule(project) {
3095
3129
  return {
3096
3130
  path: 'src/lib/i18n.ts',
3097
3131
  description: 'Localization: locales, the current-locale store, the message catalog, and t() / localizeCols helpers.',
3098
- contents: `import { writable, derived } from 'svelte/store'
3099
-
3100
- export type Locale = ${localeUnion}
3101
- export const locales: Locale[] = ${JSON.stringify(locales)} as Locale[]
3102
- export const currentLocale = writable<Locale>(${JSON.stringify(def)})
3103
-
3104
- // Seeded from your schema + screen labels. Every locale starts with the same
3105
- // keys and the default-locale text, so translating is editing values in place -
3106
- // no key hunting. Anything you delete falls back to the default locale.
3107
- const messages: Record<Locale, Record<string, string>> = {
3108
- ${messagesEntries}
3109
- }
3110
-
3111
- /** Reactive translator: \`$t('key', 'fallback')\`. Falls back to the default locale, then the fallback, then the key. */
3112
- export const t = derived(currentLocale, ($l) => (key: string, fallback?: string): string =>
3113
- messages[$l]?.[key] ?? messages[${JSON.stringify(def)}]?.[key] ?? fallback ?? key)
3114
-
3115
- /** Localize a column list's headers via \`col.<entity>.<field>\` keys. */
3116
- export function localizeCols<T extends { field?: string | number; header?: string }>(
3117
- cols: T[], entity: string, translate: (k: string, fb?: string) => string,
3118
- ): T[] {
3119
- return cols.map((c) => ({ ...c, header: translate('col.' + entity + '.' + String(c.field), c.header ?? String(c.field ?? '')) }))
3120
- }
3132
+ contents: `import { writable, derived } from 'svelte/store'
3133
+
3134
+ export type Locale = ${localeUnion}
3135
+ export const locales: Locale[] = ${JSON.stringify(locales)} as Locale[]
3136
+ export const currentLocale = writable<Locale>(${JSON.stringify(def)})
3137
+
3138
+ // Seeded from your schema + screen labels. Every locale starts with the same
3139
+ // keys and the default-locale text, so translating is editing values in place -
3140
+ // no key hunting. Anything you delete falls back to the default locale.
3141
+ const messages: Record<Locale, Record<string, string>> = {
3142
+ ${messagesEntries}
3143
+ }
3144
+
3145
+ /** Reactive translator: \`$t('key', 'fallback')\`. Falls back to the default locale, then the fallback, then the key. */
3146
+ export const t = derived(currentLocale, ($l) => (key: string, fallback?: string): string =>
3147
+ messages[$l]?.[key] ?? messages[${JSON.stringify(def)}]?.[key] ?? fallback ?? key)
3148
+
3149
+ /** Localize a column list's headers via \`col.<entity>.<field>\` keys. */
3150
+ export function localizeCols<T extends { field?: string | number; header?: string }>(
3151
+ cols: T[], entity: string, translate: (k: string, fb?: string) => string,
3152
+ ): T[] {
3153
+ return cols.map((c) => ({ ...c, header: translate('col.' + entity + '.' + String(c.field), c.header ?? String(c.field ?? '')) }))
3154
+ }
3121
3155
  `,
3122
3156
  };
3123
3157
  }
@@ -3134,60 +3168,60 @@ function auditModule(persisted = false) {
3134
3168
  return {
3135
3169
  path: 'src/lib/audit.ts',
3136
3170
  description: 'Audit trail store: the AuditEntry schema, an in-memory source, and recordAudit(). NOT persisted - enable the typed data layer to write to a real table.',
3137
- contents: `import { createInMemoryDataSource } from '@svgrid/enterprise'
3138
- import type { EntitySchema } from '@svgrid/enterprise'
3139
-
3140
- export type AuditEntry = {
3141
- id: string
3142
- at: string
3143
- actor: string
3144
- entity: string
3145
- action: 'create' | 'update' | 'delete'
3146
- recordId: string
3147
- summary: string
3148
- }
3149
-
3150
- export const auditSchema: EntitySchema<AuditEntry> = {
3151
- name: 'audit',
3152
- idField: 'id',
3153
- fields: [
3154
- { field: 'id', type: 'text', primaryKey: true, readonly: true },
3155
- { field: 'at', type: 'datetime', label: 'When' },
3156
- { field: 'actor', type: 'text', label: 'Actor' },
3157
- { field: 'entity', type: 'text', label: 'Entity' },
3158
- { field: 'action', type: 'enum', label: 'Action', options: [{ value: 'create', label: 'Create' }, { value: 'update', label: 'Update' }, { value: 'delete', label: 'Delete' }] },
3159
- { field: 'recordId', type: 'text', label: 'Record' },
3160
- { field: 'summary', type: 'text', label: 'Summary' },
3161
- ],
3162
- }
3163
-
3164
- // In-memory, server-side singleton. Replace with a SQL / Supabase source to
3165
- // persist the trail across restarts (recordAudit + the /audit viewer keep working).
3166
- export const auditSource = createInMemoryDataSource<AuditEntry>([], auditSchema)
3167
- let seq = 0
3168
-
3169
- /** Append one change record. Called by the API routes' \`audit\` hook. */
3170
- export async function recordAudit(input: {
3171
- entity: string
3172
- action: 'create' | 'update' | 'delete'
3173
- recordId: string | null
3174
- values?: Record<string, unknown>
3175
- actor?: string
3176
- }): Promise<void> {
3177
- const summary =
3178
- input.action === 'delete'
3179
- ? \`Deleted \${input.entity} \${input.recordId ?? ''}\`.trim()
3180
- : \`\${input.action === 'create' ? 'Created' : 'Updated'} \${input.entity}\${input.values ? ' (' + Object.keys(input.values).join(', ') + ')' : ''}\`
3181
- await auditSource.createRow?.({
3182
- id: String(++seq),
3183
- at: new Date().toISOString(),
3184
- actor: input.actor ?? 'system',
3185
- entity: input.entity,
3186
- action: input.action,
3187
- recordId: input.recordId ?? '',
3188
- summary,
3189
- })
3190
- }
3171
+ contents: `import { createInMemoryDataSource } from '@svgrid/enterprise'
3172
+ import type { EntitySchema } from '@svgrid/enterprise'
3173
+
3174
+ export type AuditEntry = {
3175
+ id: string
3176
+ at: string
3177
+ actor: string
3178
+ entity: string
3179
+ action: 'create' | 'update' | 'delete'
3180
+ recordId: string
3181
+ summary: string
3182
+ }
3183
+
3184
+ export const auditSchema: EntitySchema<AuditEntry> = {
3185
+ name: 'audit',
3186
+ idField: 'id',
3187
+ fields: [
3188
+ { field: 'id', type: 'text', primaryKey: true, readonly: true },
3189
+ { field: 'at', type: 'datetime', label: 'When' },
3190
+ { field: 'actor', type: 'text', label: 'Actor' },
3191
+ { field: 'entity', type: 'text', label: 'Entity' },
3192
+ { field: 'action', type: 'enum', label: 'Action', options: [{ value: 'create', label: 'Create' }, { value: 'update', label: 'Update' }, { value: 'delete', label: 'Delete' }] },
3193
+ { field: 'recordId', type: 'text', label: 'Record' },
3194
+ { field: 'summary', type: 'text', label: 'Summary' },
3195
+ ],
3196
+ }
3197
+
3198
+ // In-memory, server-side singleton. Replace with a SQL / Supabase source to
3199
+ // persist the trail across restarts (recordAudit + the /audit viewer keep working).
3200
+ export const auditSource = createInMemoryDataSource<AuditEntry>([], auditSchema)
3201
+ let seq = 0
3202
+
3203
+ /** Append one change record. Called by the API routes' \`audit\` hook. */
3204
+ export async function recordAudit(input: {
3205
+ entity: string
3206
+ action: 'create' | 'update' | 'delete'
3207
+ recordId: string | null
3208
+ values?: Record<string, unknown>
3209
+ actor?: string
3210
+ }): Promise<void> {
3211
+ const summary =
3212
+ input.action === 'delete'
3213
+ ? \`Deleted \${input.entity} \${input.recordId ?? ''}\`.trim()
3214
+ : \`\${input.action === 'create' ? 'Created' : 'Updated'} \${input.entity}\${input.values ? ' (' + Object.keys(input.values).join(', ') + ')' : ''}\`
3215
+ await auditSource.createRow?.({
3216
+ id: String(++seq),
3217
+ at: new Date().toISOString(),
3218
+ actor: input.actor ?? 'system',
3219
+ entity: input.entity,
3220
+ action: input.action,
3221
+ recordId: input.recordId ?? '',
3222
+ summary,
3223
+ })
3224
+ }
3191
3225
  `,
3192
3226
  };
3193
3227
  }
@@ -3198,92 +3232,92 @@ function auditModulePersisted() {
3198
3232
  return {
3199
3233
  path: 'src/lib/audit.ts',
3200
3234
  description: 'Audit trail: the AuditEntry schema, a Drizzle-backed source over audit_log, and recordAudit() with before/after snapshots.',
3201
- contents: `import { count, desc } from 'drizzle-orm'
3202
- import type { EntitySchema } from '@svgrid/enterprise'
3203
- import { db } from '../lib/server/db'
3204
- import { auditLog } from '../lib/server/db/schema'
3205
-
3206
- export type AuditEntry = {
3207
- id: string
3208
- at: string
3209
- actor: string
3210
- entity: string
3211
- action: 'create' | 'update' | 'delete'
3212
- recordId: string
3213
- summary: string
3214
- /** JSON snapshot of the row before the change (update / delete). */
3215
- before?: string | null
3216
- /** JSON snapshot of the row after it (create / update). */
3217
- after?: string | null
3218
- }
3219
-
3220
- export const auditSchema: EntitySchema<AuditEntry> = {
3221
- name: 'audit',
3222
- idField: 'id',
3223
- fields: [
3224
- { field: 'id', type: 'text', primaryKey: true, readonly: true },
3225
- { field: 'at', type: 'datetime', label: 'When' },
3226
- { field: 'actor', type: 'text', label: 'Actor' },
3227
- { field: 'entity', type: 'text', label: 'Entity' },
3228
- { field: 'action', type: 'enum', label: 'Action', options: [{ value: 'create', label: 'Create' }, { value: 'update', label: 'Update' }, { value: 'delete', label: 'Delete' }] },
3229
- { field: 'recordId', type: 'text', label: 'Record' },
3230
- { field: 'summary', type: 'text', label: 'Summary' },
3231
- { field: 'before', type: 'json', label: 'Before', readonly: true },
3232
- { field: 'after', type: 'json', label: 'After', readonly: true },
3233
- ],
3234
- }
3235
-
3236
- const toEntry = (r: typeof auditLog.$inferSelect): AuditEntry => ({
3237
- id: String(r.id),
3238
- at: typeof r.at === 'string' ? r.at : new Date(r.at as unknown as Date).toISOString(),
3239
- actor: r.actor,
3240
- entity: r.entity,
3241
- action: r.action as AuditEntry['action'],
3242
- recordId: r.recordId,
3243
- summary: r.summary,
3244
- before: r.before ?? null,
3245
- after: r.after ?? null,
3246
- })
3247
-
3248
- /** Read-only \\\`ServerDataSource\\\` for the /audit viewer. Newest first, paged in
3249
- * the database so a long trail doesn't load in one go. No write methods: the
3250
- * trail is append-only through \\\`recordAudit\\\`. */
3251
- export const auditSource = {
3252
- async getRows(request: { startRow?: number; endRow?: number }) {
3253
- const start = request.startRow ?? 0
3254
- const limit = Math.max(1, (request.endRow ?? start + 25) - start)
3255
- const [rows, counted] = await Promise.all([
3256
- db.select().from(auditLog).orderBy(desc(auditLog.at)).limit(limit).offset(start),
3257
- db.select({ n: count() }).from(auditLog),
3258
- ])
3259
- return { rows: rows.map(toEntry), rowCount: Number(counted.at(0)?.n ?? 0) }
3260
- },
3261
- }
3262
-
3263
- /** Append one change record. Called by the API routes' \\\`audit\\\` hook. */
3264
- export async function recordAudit(input: {
3265
- entity: string
3266
- action: 'create' | 'update' | 'delete'
3267
- recordId: string | null
3268
- values?: Record<string, unknown>
3269
- before?: Record<string, unknown> | null
3270
- actor?: string
3271
- }): Promise<void> {
3272
- const summary =
3273
- input.action === 'delete'
3274
- ? \`Deleted \${input.entity} \${input.recordId ?? ''}\`.trim()
3275
- : \`\${input.action === 'create' ? 'Created' : 'Updated'} \${input.entity}\${input.values ? ' (' + Object.keys(input.values).join(', ') + ')' : ''}\`
3276
- await db.insert(auditLog).values({
3277
- at: new Date().toISOString(),
3278
- actor: input.actor ?? 'system',
3279
- entity: input.entity,
3280
- action: input.action,
3281
- recordId: input.recordId ?? '',
3282
- summary,
3283
- before: input.before ? JSON.stringify(input.before) : null,
3284
- after: input.values ? JSON.stringify(input.values) : null,
3285
- } as typeof auditLog.$inferInsert)
3286
- }
3235
+ contents: `import { count, desc } from 'drizzle-orm'
3236
+ import type { EntitySchema } from '@svgrid/enterprise'
3237
+ import { db } from '../lib/server/db'
3238
+ import { auditLog } from '../lib/server/db/schema'
3239
+
3240
+ export type AuditEntry = {
3241
+ id: string
3242
+ at: string
3243
+ actor: string
3244
+ entity: string
3245
+ action: 'create' | 'update' | 'delete'
3246
+ recordId: string
3247
+ summary: string
3248
+ /** JSON snapshot of the row before the change (update / delete). */
3249
+ before?: string | null
3250
+ /** JSON snapshot of the row after it (create / update). */
3251
+ after?: string | null
3252
+ }
3253
+
3254
+ export const auditSchema: EntitySchema<AuditEntry> = {
3255
+ name: 'audit',
3256
+ idField: 'id',
3257
+ fields: [
3258
+ { field: 'id', type: 'text', primaryKey: true, readonly: true },
3259
+ { field: 'at', type: 'datetime', label: 'When' },
3260
+ { field: 'actor', type: 'text', label: 'Actor' },
3261
+ { field: 'entity', type: 'text', label: 'Entity' },
3262
+ { field: 'action', type: 'enum', label: 'Action', options: [{ value: 'create', label: 'Create' }, { value: 'update', label: 'Update' }, { value: 'delete', label: 'Delete' }] },
3263
+ { field: 'recordId', type: 'text', label: 'Record' },
3264
+ { field: 'summary', type: 'text', label: 'Summary' },
3265
+ { field: 'before', type: 'json', label: 'Before', readonly: true },
3266
+ { field: 'after', type: 'json', label: 'After', readonly: true },
3267
+ ],
3268
+ }
3269
+
3270
+ const toEntry = (r: typeof auditLog.$inferSelect): AuditEntry => ({
3271
+ id: String(r.id),
3272
+ at: typeof r.at === 'string' ? r.at : new Date(r.at as unknown as Date).toISOString(),
3273
+ actor: r.actor,
3274
+ entity: r.entity,
3275
+ action: r.action as AuditEntry['action'],
3276
+ recordId: r.recordId,
3277
+ summary: r.summary,
3278
+ before: r.before ?? null,
3279
+ after: r.after ?? null,
3280
+ })
3281
+
3282
+ /** Read-only \\\`ServerDataSource\\\` for the /audit viewer. Newest first, paged in
3283
+ * the database so a long trail doesn't load in one go. No write methods: the
3284
+ * trail is append-only through \\\`recordAudit\\\`. */
3285
+ export const auditSource = {
3286
+ async getRows(request: { startRow?: number; endRow?: number }) {
3287
+ const start = request.startRow ?? 0
3288
+ const limit = Math.max(1, (request.endRow ?? start + 25) - start)
3289
+ const [rows, counted] = await Promise.all([
3290
+ db.select().from(auditLog).orderBy(desc(auditLog.at)).limit(limit).offset(start),
3291
+ db.select({ n: count() }).from(auditLog),
3292
+ ])
3293
+ return { rows: rows.map(toEntry), rowCount: Number(counted.at(0)?.n ?? 0) }
3294
+ },
3295
+ }
3296
+
3297
+ /** Append one change record. Called by the API routes' \\\`audit\\\` hook. */
3298
+ export async function recordAudit(input: {
3299
+ entity: string
3300
+ action: 'create' | 'update' | 'delete'
3301
+ recordId: string | null
3302
+ values?: Record<string, unknown>
3303
+ before?: Record<string, unknown> | null
3304
+ actor?: string
3305
+ }): Promise<void> {
3306
+ const summary =
3307
+ input.action === 'delete'
3308
+ ? \`Deleted \${input.entity} \${input.recordId ?? ''}\`.trim()
3309
+ : \`\${input.action === 'create' ? 'Created' : 'Updated'} \${input.entity}\${input.values ? ' (' + Object.keys(input.values).join(', ') + ')' : ''}\`
3310
+ await db.insert(auditLog).values({
3311
+ at: new Date().toISOString(),
3312
+ actor: input.actor ?? 'system',
3313
+ entity: input.entity,
3314
+ action: input.action,
3315
+ recordId: input.recordId ?? '',
3316
+ summary,
3317
+ before: input.before ? JSON.stringify(input.before) : null,
3318
+ after: input.values ? JSON.stringify(input.values) : null,
3319
+ } as typeof auditLog.$inferInsert)
3320
+ }
3287
3321
  `,
3288
3322
  };
3289
3323
  }
@@ -3298,31 +3332,31 @@ function tenantModule(field) {
3298
3332
  return {
3299
3333
  path: 'src/lib/server/tenant.ts',
3300
3334
  description: 'Resolve the caller\'s tenant from the session; used to scope every API route.',
3301
- contents: `// Regenerated by SvGrid Studio. Multi-tenancy: which tenant is calling?
3302
- //
3303
- // The tenant is carried on the session (see hooks.server.ts / auth.ts) and
3304
- // stamped onto \`event.locals\`. Every scoped API route calls requireTenant().
3305
-
3306
- export type TenantEvent = { locals?: Record<string, unknown> }
3307
-
3308
- /** The caller's tenant id, or null when there is none. */
3309
- export function getTenant(event: TenantEvent): string | null {
3310
- const t = event.locals?.${field}
3311
- return t == null || t === '' ? null : String(t)
3312
- }
3313
-
3314
- /**
3315
- * The caller's tenant id, or THROW.
3316
- *
3317
- * Throwing is the point: the route's \`scope\` turns it into a 403. Returning
3318
- * null here would let the query run unscoped, which is the one failure mode
3319
- * multi-tenancy cannot have.
3320
- */
3321
- export function requireTenant(event: TenantEvent): string {
3322
- const t = getTenant(event)
3323
- if (!t) throw new Error('No tenant on the session - sign in again.')
3324
- return t
3325
- }
3335
+ contents: `// Regenerated by SvGrid Studio. Multi-tenancy: which tenant is calling?
3336
+ //
3337
+ // The tenant is carried on the session (see hooks.server.ts / auth.ts) and
3338
+ // stamped onto \`event.locals\`. Every scoped API route calls requireTenant().
3339
+
3340
+ export type TenantEvent = { locals?: Record<string, unknown> }
3341
+
3342
+ /** The caller's tenant id, or null when there is none. */
3343
+ export function getTenant(event: TenantEvent): string | null {
3344
+ const t = event.locals?.${field}
3345
+ return t == null || t === '' ? null : String(t)
3346
+ }
3347
+
3348
+ /**
3349
+ * The caller's tenant id, or THROW.
3350
+ *
3351
+ * Throwing is the point: the route's \`scope\` turns it into a 403. Returning
3352
+ * null here would let the query run unscoped, which is the one failure mode
3353
+ * multi-tenancy cannot have.
3354
+ */
3355
+ export function requireTenant(event: TenantEvent): string {
3356
+ const t = getTenant(event)
3357
+ if (!t) throw new Error('No tenant on the session - sign in again.')
3358
+ return t
3359
+ }
3326
3360
  `,
3327
3361
  };
3328
3362
  }
@@ -3347,10 +3381,10 @@ function jobsFiles(project, jobs, emailAvailable) {
3347
3381
  return ` ${JSON.stringify(j.id)}: async () => {\n // ${label}: cannot send - ${why}.\n console.warn('cron ${j.id}: skipped (${why})')\n },`;
3348
3382
  }
3349
3383
  const n = namesFor(ent);
3350
- return ` ${JSON.stringify(j.id)}: async () => {
3351
- const { rows, rowCount } = await ${n.sourceVar}.getRows({ startRow: 0, endRow: 10, sortModel: [], filterModel: {} })
3352
- const items = rows.map((r) => '<li>' + Object.values(r).slice(0, 3).map(String).join(' &middot; ') + '</li>').join('')
3353
- await sendEmail(${to}, ${subject}, '<p>' + rowCount + ' ${ent.name} total. Most recent:</p><ul>' + items + '</ul>')
3384
+ return ` ${JSON.stringify(j.id)}: async () => {
3385
+ const { rows, rowCount } = await ${n.sourceVar}.getRows({ startRow: 0, endRow: 10, sortModel: [], filterModel: {} })
3386
+ const items = rows.map((r) => '<li>' + Object.values(r).slice(0, 3).map(String).join(' &middot; ') + '</li>').join('')
3387
+ await sendEmail(${to}, ${subject}, '<p>' + rowCount + ' ${ent.name} total. Most recent:</p><ul>' + items + '</ul>')
3354
3388
  },`;
3355
3389
  }
3356
3390
  const body = (j.code ?? '').trim() || `console.log('cron ${j.id}: no body yet')`;
@@ -3369,59 +3403,59 @@ function jobsFiles(project, jobs, emailAvailable) {
3369
3403
  ...(jobs.some((j) => j.kind === 'email') && emailAvailable ? [`import { sendEmail } from '../lib/server/email'`] : []),
3370
3404
  ];
3371
3405
  const table = jobs.map((j) => ` * ${j.id.padEnd(20)} ${j.cron.padEnd(16)} ${j.name}`).join('\n');
3372
- const jobsTs = `// Regenerated by SvGrid Studio. Scheduled job handlers.
3373
- //
3374
- // Schedule (UTC):
3375
- ${table}
3376
- //
3377
- // Runs on the server, triggered by /api/cron - NOT in the browser. Edit a
3378
- // handler body freely; the registry keys are what /api/cron dispatches on.
3379
- ${imports.join('\n')}
3380
-
3381
- export const jobs: Record<string, () => Promise<void>> = {
3382
- ${jobs.map(handler).join('\n')}
3383
- }
3384
-
3385
- /** Job ids that are switched on. /api/cron without ?job= runs exactly these. */
3386
- export const enabledJobs: string[] = ${JSON.stringify(jobs.filter((j) => j.enabled !== false).map((j) => j.id))}
3406
+ const jobsTs = `// Regenerated by SvGrid Studio. Scheduled job handlers.
3407
+ //
3408
+ // Schedule (UTC):
3409
+ ${table}
3410
+ //
3411
+ // Runs on the server, triggered by /api/cron - NOT in the browser. Edit a
3412
+ // handler body freely; the registry keys are what /api/cron dispatches on.
3413
+ ${imports.join('\n')}
3414
+
3415
+ export const jobs: Record<string, () => Promise<void>> = {
3416
+ ${jobs.map(handler).join('\n')}
3417
+ }
3418
+
3419
+ /** Job ids that are switched on. /api/cron without ?job= runs exactly these. */
3420
+ export const enabledJobs: string[] = ${JSON.stringify(jobs.filter((j) => j.enabled !== false).map((j) => j.id))}
3387
3421
  `;
3388
- const routeTs = `import { json, error } from '@sveltejs/kit'
3389
- import { env } from '$env/dynamic/private'
3390
- import type { RequestHandler } from './$types'
3391
- import { jobs, enabledJobs } from '../lib/server/jobs'
3392
-
3393
- /**
3394
- * Scheduled-job endpoint. Your platform's scheduler calls this (Vercel Cron, a
3395
- * GitHub Actions schedule, or a crontab running \`curl\`). See DEPLOY.md.
3396
- *
3397
- * Guarded by CRON_SECRET: send it as \`Authorization: Bearer <secret>\` or
3398
- * \`?secret=<secret>\`. With no CRON_SECRET set the route refuses to run rather
3399
- * than leaving a public "do work" URL open.
3400
- */
3401
- const authorized = (request: Request, url: URL): boolean => {
3402
- const secret = env.CRON_SECRET
3403
- if (!secret) return false
3404
- const header = request.headers.get('authorization')
3405
- return header === 'Bearer ' + secret || url.searchParams.get('secret') === secret
3406
- }
3407
-
3408
- const run: RequestHandler = async ({ request, url }) => {
3409
- if (!authorized(request, url)) throw error(401, 'cron: missing or invalid CRON_SECRET')
3410
- const only = url.searchParams.get('job')
3411
- const ids = only ? [only] : enabledJobs
3412
- const results: Array<{ job: string; ok: boolean; error?: string }> = []
3413
- for (const id of ids) {
3414
- const fn = jobs[id]
3415
- if (!fn) { results.push({ job: id, ok: false, error: 'unknown job' }); continue }
3416
- // One failing job must not stop the rest of the run.
3417
- try { await fn(); results.push({ job: id, ok: true }) }
3418
- catch (err) { results.push({ job: id, ok: false, error: err instanceof Error ? err.message : String(err) }) }
3419
- }
3420
- return json({ ran: results.length, results })
3421
- }
3422
-
3423
- export const GET = run
3424
- export const POST = run
3422
+ const routeTs = `import { json, error } from '@sveltejs/kit'
3423
+ import { env } from '$env/dynamic/private'
3424
+ import type { RequestHandler } from './$types'
3425
+ import { jobs, enabledJobs } from '../lib/server/jobs'
3426
+
3427
+ /**
3428
+ * Scheduled-job endpoint. Your platform's scheduler calls this (Vercel Cron, a
3429
+ * GitHub Actions schedule, or a crontab running \`curl\`). See DEPLOY.md.
3430
+ *
3431
+ * Guarded by CRON_SECRET: send it as \`Authorization: Bearer <secret>\` or
3432
+ * \`?secret=<secret>\`. With no CRON_SECRET set the route refuses to run rather
3433
+ * than leaving a public "do work" URL open.
3434
+ */
3435
+ const authorized = (request: Request, url: URL): boolean => {
3436
+ const secret = env.CRON_SECRET
3437
+ if (!secret) return false
3438
+ const header = request.headers.get('authorization')
3439
+ return header === 'Bearer ' + secret || url.searchParams.get('secret') === secret
3440
+ }
3441
+
3442
+ const run: RequestHandler = async ({ request, url }) => {
3443
+ if (!authorized(request, url)) throw error(401, 'cron: missing or invalid CRON_SECRET')
3444
+ const only = url.searchParams.get('job')
3445
+ const ids = only ? [only] : enabledJobs
3446
+ const results: Array<{ job: string; ok: boolean; error?: string }> = []
3447
+ for (const id of ids) {
3448
+ const fn = jobs[id]
3449
+ if (!fn) { results.push({ job: id, ok: false, error: 'unknown job' }); continue }
3450
+ // One failing job must not stop the rest of the run.
3451
+ try { await fn(); results.push({ job: id, ok: true }) }
3452
+ catch (err) { results.push({ job: id, ok: false, error: err instanceof Error ? err.message : String(err) }) }
3453
+ }
3454
+ return json({ ran: results.length, results })
3455
+ }
3456
+
3457
+ export const GET = run
3458
+ export const POST = run
3425
3459
  `;
3426
3460
  return [
3427
3461
  { path: 'src/lib/server/jobs.ts', description: 'Scheduled job handlers, keyed by job id.', contents: jobsTs },
@@ -3433,10 +3467,10 @@ function auditRouteFile() {
3433
3467
  return {
3434
3468
  path: 'src/routes/api/audit/+server.ts',
3435
3469
  description: 'API route for the audit trail (read-only viewer feed).',
3436
- contents: `import { createKitHandlers } from '@svgrid/enterprise'
3437
- import { auditSchema, auditSource } from '../lib/audit'
3438
-
3439
- export const { POST } = createKitHandlers({ schema: auditSchema, source: auditSource })
3470
+ contents: `import { createKitHandlers } from '@svgrid/enterprise'
3471
+ import { auditSchema, auditSource } from '../lib/audit'
3472
+
3473
+ export const { POST } = createKitHandlers({ schema: auditSchema, source: auditSource })
3440
3474
  `,
3441
3475
  };
3442
3476
  }
@@ -3445,40 +3479,40 @@ function auditViewerPage() {
3445
3479
  return {
3446
3480
  path: 'src/routes/audit/+page.svelte',
3447
3481
  description: 'Audit log viewer (read-only grid of change records).',
3448
- contents: `<script lang="ts">
3449
- import { SvGrid, createServerDataSource, type ServerState } from '@svgrid/grid'
3450
- import { schemaToColumns, createKitDataSource } from '@svgrid/enterprise'
3451
- import { auditSchema, type AuditEntry } from '../lib/audit'
3452
-
3453
- const source = createKitDataSource<AuditEntry>({ endpoint: '/api/audit' })
3454
- let view = $state<ServerState<AuditEntry>>({ rows: [], total: 0, loading: false, saving: false, error: null, pageIndex: 0, pageSize: 25, pageCount: 1, sortModel: [], filterModel: {} })
3455
- const controller = createServerDataSource<AuditEntry>(source, { pageSize: 25, onChange: (s) => (view = s) })
3456
- $effect(() => { controller.refresh(); return () => controller.dispose() })
3457
- const columns = schemaToColumns(auditSchema)
3458
- </script>
3459
-
3460
- <h1 class="st__title">Audit log</h1>
3461
- <p class="st__sub">Every create, update, and delete recorded server-side.</p>
3462
-
3463
- <div class="screen" style="margin-top: 16px">
3464
- <SvGrid
3465
- data={view.rows}
3466
- columns={columns}
3467
- loading={view.loading}
3468
- loadingOverlay
3469
- fitColumns
3470
- sortable
3471
- externalSort
3472
- onSortingChange={(s) => controller.setSort(s)}
3473
- showPagination
3474
- externalPagination
3475
- rowCount={view.total}
3476
- pageIndex={view.pageIndex}
3477
- pageSize={view.pageSize}
3478
- onPaginationChange={({ pageIndex, pageSize }) => (pageSize !== view.pageSize ? controller.setPageSize(pageSize) : controller.setPage(pageIndex))}
3479
- containerHeight={520}
3480
- />
3481
- </div>
3482
+ contents: `<script lang="ts">
3483
+ import { SvGrid, createServerDataSource, type ServerState } from '@svgrid/grid'
3484
+ import { schemaToColumns, createKitDataSource } from '@svgrid/enterprise'
3485
+ import { auditSchema, type AuditEntry } from '../lib/audit'
3486
+
3487
+ const source = createKitDataSource<AuditEntry>({ endpoint: '/api/audit' })
3488
+ let view = $state<ServerState<AuditEntry>>({ rows: [], total: 0, loading: false, saving: false, error: null, pageIndex: 0, pageSize: 25, pageCount: 1, sortModel: [], filterModel: {} })
3489
+ const controller = createServerDataSource<AuditEntry>(source, { pageSize: 25, onChange: (s) => (view = s) })
3490
+ $effect(() => { controller.refresh(); return () => controller.dispose() })
3491
+ const columns = schemaToColumns(auditSchema)
3492
+ </script>
3493
+
3494
+ <h1 class="st__title">Audit log</h1>
3495
+ <p class="st__sub">Every create, update, and delete recorded server-side.</p>
3496
+
3497
+ <div class="screen" style="margin-top: 16px">
3498
+ <SvGrid
3499
+ data={view.rows}
3500
+ columns={columns}
3501
+ loading={view.loading}
3502
+ loadingOverlay
3503
+ fitColumns
3504
+ sortable
3505
+ externalSort
3506
+ onSortingChange={(s) => controller.setSort(s)}
3507
+ showPagination
3508
+ externalPagination
3509
+ rowCount={view.total}
3510
+ pageIndex={view.pageIndex}
3511
+ pageSize={view.pageSize}
3512
+ onPaginationChange={({ pageIndex, pageSize }) => (pageSize !== view.pageSize ? controller.setPageSize(pageSize) : controller.setPage(pageIndex))}
3513
+ containerHeight={520}
3514
+ />
3515
+ </div>
3482
3516
  `,
3483
3517
  };
3484
3518
  }
@@ -3494,51 +3528,51 @@ function accessModule(project) {
3494
3528
  return {
3495
3529
  path: 'src/lib/access.ts',
3496
3530
  description: 'RBAC policy: roles, screen + action permissions, the current-role store, and server helpers. Shared by the UI and the API routes.',
3497
- contents: `import { writable } from 'svelte/store'
3498
-
3499
- export type AppRole = ${roleUnion}
3500
- export type WriteAction = 'create' | 'update' | 'delete'
3501
- export const ROLES: AppRole[] = ${JSON.stringify(roleNames)} as AppRole[]
3502
-
3503
- const SCREENS: Record<AppRole, '*' | string[]> = {
3504
- ${screensEntries}
3505
- }
3506
- const ACTIONS: Record<AppRole, '*' | WriteAction[]> = {
3507
- ${actionsEntries}
3508
- }
3509
-
3510
- /** The signed-in user's role. Set it after login (e.g. from the session);
3511
- * defaults to the project's default role. Read it in components as \`$currentRole\`. */
3512
- export const currentRole = writable<AppRole>(${JSON.stringify(defaultRole)})
3513
-
3514
- /** May this role open the given screen id? */
3515
- export function canScreen(role: AppRole, screenId: string): boolean {
3516
- const s = SCREENS[role]
3517
- return s === '*' || (Array.isArray(s) && s.includes(screenId))
3518
- }
3519
- /** May this role perform a write action? (Reads are implied by screen access.) */
3520
- export function can(role: AppRole, action: WriteAction): boolean {
3521
- const a = ACTIONS[role]
3522
- return a === '*' || (Array.isArray(a) && a.includes(action))
3523
- }
3524
-
3525
- // ---- server side ----------------------------------------------------------
3526
- /** Resolve the caller's role on the server. Wire this to YOUR auth: by default it
3527
- * reads \`event.locals.role\` - set it in \`hooks.server.ts\` from the session. */
3528
- export function getServerRole(event: { locals?: { role?: unknown } }): AppRole {
3529
- const r = event?.locals?.role
3530
- return (typeof r === 'string' && (ROLES as string[]).includes(r) ? r : ${JSON.stringify(defaultRole)}) as AppRole
3531
- }
3532
- /** Authorize a CRUD action for a role - used by the API routes' \`authorize\` hook.
3533
- * \`screenIds\` are the screen(s) bound to this route's entity: a read is allowed
3534
- * only if the role can open at least one of them (an entity with no screen of its
3535
- * own - e.g. a relation lookup target - has nothing to gate reads by, so it stays
3536
- * open). Writes are still governed purely by \`can()\`. */
3537
- export function authorizeAction(role: AppRole, action: 'read' | WriteAction, screenIds: string[] = []): boolean {
3538
- if (action !== 'read') return can(role, action)
3539
- if (screenIds.length === 0) return true
3540
- return screenIds.some((id) => canScreen(role, id))
3541
- }
3531
+ contents: `import { writable } from 'svelte/store'
3532
+
3533
+ export type AppRole = ${roleUnion}
3534
+ export type WriteAction = 'create' | 'update' | 'delete'
3535
+ export const ROLES: AppRole[] = ${JSON.stringify(roleNames)} as AppRole[]
3536
+
3537
+ const SCREENS: Record<AppRole, '*' | string[]> = {
3538
+ ${screensEntries}
3539
+ }
3540
+ const ACTIONS: Record<AppRole, '*' | WriteAction[]> = {
3541
+ ${actionsEntries}
3542
+ }
3543
+
3544
+ /** The signed-in user's role. Set it after login (e.g. from the session);
3545
+ * defaults to the project's default role. Read it in components as \`$currentRole\`. */
3546
+ export const currentRole = writable<AppRole>(${JSON.stringify(defaultRole)})
3547
+
3548
+ /** May this role open the given screen id? */
3549
+ export function canScreen(role: AppRole, screenId: string): boolean {
3550
+ const s = SCREENS[role]
3551
+ return s === '*' || (Array.isArray(s) && s.includes(screenId))
3552
+ }
3553
+ /** May this role perform a write action? (Reads are implied by screen access.) */
3554
+ export function can(role: AppRole, action: WriteAction): boolean {
3555
+ const a = ACTIONS[role]
3556
+ return a === '*' || (Array.isArray(a) && a.includes(action))
3557
+ }
3558
+
3559
+ // ---- server side ----------------------------------------------------------
3560
+ /** Resolve the caller's role on the server. Wire this to YOUR auth: by default it
3561
+ * reads \`event.locals.role\` - set it in \`hooks.server.ts\` from the session. */
3562
+ export function getServerRole(event: { locals?: { role?: unknown } }): AppRole {
3563
+ const r = event?.locals?.role
3564
+ return (typeof r === 'string' && (ROLES as string[]).includes(r) ? r : ${JSON.stringify(defaultRole)}) as AppRole
3565
+ }
3566
+ /** Authorize a CRUD action for a role - used by the API routes' \`authorize\` hook.
3567
+ * \`screenIds\` are the screen(s) bound to this route's entity: a read is allowed
3568
+ * only if the role can open at least one of them (an entity with no screen of its
3569
+ * own - e.g. a relation lookup target - has nothing to gate reads by, so it stays
3570
+ * open). Writes are still governed purely by \`can()\`. */
3571
+ export function authorizeAction(role: AppRole, action: 'read' | WriteAction, screenIds: string[] = []): boolean {
3572
+ if (action !== 'read') return can(role, action)
3573
+ if (screenIds.length === 0) return true
3574
+ return screenIds.some((id) => canScreen(role, id))
3575
+ }
3542
3576
  `,
3543
3577
  };
3544
3578
  }
@@ -3569,862 +3603,862 @@ function authFiles(project, dbBacked = false, accessEnabled = false, tenantColum
3569
3603
  const seedLiteral = users
3570
3604
  .map((u) => ` { email: ${JSON.stringify(u.email)}, name: ${JSON.stringify(u.name)}, role: ${JSON.stringify(u.role)}, password: ${JSON.stringify(u.password)} }`)
3571
3605
  .join(',\n');
3572
- const authTs = `// Regenerated by SvGrid Studio. Dependency-free auth: PBKDF2 password hashing +
3573
- // stateless HMAC-signed session cookies (Web Crypto - runs on Node and edge runtimes).
3574
- import { env } from '$env/dynamic/private'
3575
-
3576
- export const SESSION_COOKIE = 'sv_session'
3577
- export const SESSION_MAX_AGE = 60 * 60 * 24 * 7 // 7 days (seconds)
3578
-
3579
- export type SessionUser = { email: string; name: string; role: string }
3580
-
3581
- const enc = new TextEncoder()
3582
- // Signing secret. Set SESSION_SECRET in the environment for production (see .env.example).
3583
- const secret = () => env.SESSION_SECRET || 'dev-insecure-secret-change-me'
3584
-
3585
- function b64url(bytes: ArrayBuffer | Uint8Array): string {
3586
- const b = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
3587
- let s = ''
3588
- for (const byte of b) s += String.fromCharCode(byte)
3589
- return btoa(s).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')
3590
- }
3591
- function fromB64url(s: string): Uint8Array {
3592
- const p = s.replace(/-/g, '+').replace(/_/g, '/')
3593
- const bin = atob(p + '==='.slice((p.length + 3) % 4))
3594
- const out = new Uint8Array(bin.length)
3595
- for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
3596
- return out
3597
- }
3598
- function timingSafeEqual(a: string, b: string): boolean {
3599
- if (a.length !== b.length) return false
3600
- let out = 0
3601
- for (let i = 0; i < a.length; i++) out |= a.charCodeAt(i) ^ b.charCodeAt(i)
3602
- return out === 0
3603
- }
3604
- async function hmac(data: string): Promise<string> {
3605
- const key = await crypto.subtle.importKey('raw', enc.encode(secret()), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
3606
- return b64url(await crypto.subtle.sign('HMAC', key, enc.encode(data)))
3607
- }
3608
-
3609
- /** Sign a stateless session token: base64url(payload).hmac(payload). */
3610
- export async function signSession(user: SessionUser): Promise<string> {
3611
- const payload = { ...user, exp: Math.floor(Date.now() / 1000) + SESSION_MAX_AGE }
3612
- const body = b64url(enc.encode(JSON.stringify(payload)))
3613
- return body + '.' + (await hmac(body))
3614
- }
3615
- /** Verify a token; returns the user, or null if missing / tampered / expired. */
3616
- export async function readSession(token: string | undefined): Promise<SessionUser | null> {
3617
- if (!token) return null
3618
- const dot = token.lastIndexOf('.')
3619
- if (dot < 0) return null
3620
- const body = token.slice(0, dot)
3621
- const sig = token.slice(dot + 1)
3622
- if (!timingSafeEqual(sig, await hmac(body))) return null
3623
- try {
3624
- const p = JSON.parse(new TextDecoder().decode(fromB64url(body))) as SessionUser & { exp: number }
3625
- if (typeof p.exp !== 'number' || p.exp * 1000 < Date.now()) return null
3626
- return { email: p.email, name: p.name, role: p.role }
3627
- } catch {
3628
- return null
3629
- }
3630
- }
3631
-
3632
- // ---- password hashing (PBKDF2) --------------------------------------------
3633
- // Use these to store hashed passwords in a real user store: keep hashPassword()'s
3634
- // output as \`passwordHash\`, then check with verifyPassword(input, passwordHash).
3635
- export async function hashPassword(password: string, salt?: string): Promise<string> {
3636
- const s = salt ?? b64url(crypto.getRandomValues(new Uint8Array(16)))
3637
- const key = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveBits'])
3638
- const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt: enc.encode(s), iterations: 100000, hash: 'SHA-256' }, key, 256)
3639
- return s + ':' + b64url(bits)
3640
- }
3641
- export async function verifyPassword(password: string, stored: string): Promise<boolean> {
3642
- const salt = stored.split(':')[0]
3643
- if (!salt) return false
3644
- return timingSafeEqual(await hashPassword(password, salt), stored)
3645
- }
3646
-
3647
- // ---- password reset (stateless, signed link) ------------------------------
3648
- const RESET_MAX_AGE = 60 * 30 // 30 minutes
3649
- /** A short-lived, signed password-reset token for an email (no DB row needed). */
3650
- export async function signReset(email: string): Promise<string> {
3651
- const body = b64url(enc.encode(JSON.stringify({ email, exp: Math.floor(Date.now() / 1000) + RESET_MAX_AGE, k: 'reset' })))
3652
- return body + '.' + (await hmac(body))
3653
- }
3654
- /** Verify a reset token; returns the email, or null if invalid / expired. */
3655
- export async function readReset(token: string | undefined): Promise<string | null> {
3656
- if (!token) return null
3657
- const dot = token.lastIndexOf('.')
3658
- if (dot < 0) return null
3659
- const body = token.slice(0, dot)
3660
- if (!timingSafeEqual(token.slice(dot + 1), await hmac(body))) return null
3661
- try {
3662
- const p = JSON.parse(new TextDecoder().decode(fromB64url(body))) as { email: string; exp: number; k: string }
3663
- if (p.k !== 'reset' || typeof p.exp !== 'number' || p.exp * 1000 < Date.now()) return null
3664
- return p.email
3665
- } catch {
3666
- return null
3667
- }
3668
- }
3669
-
3670
- /** Deliver a password-reset link. STUB: logs it in dev - wire your email provider
3671
- * (Resend / SendGrid / Postmark / SMTP) here for production. */
3672
- export async function sendResetEmail(email: string, link: string): Promise<void> {
3673
- console.log('[auth] password reset for ' + email + ' -> ' + link)
3674
- }
3675
- ${twoFactor ? `
3676
- // ---- email two-factor challenge (stateless, signed) -----------------------
3677
- export const TFA_COOKIE = 'sv_2fa'
3678
- const TFA_MAX_AGE = 60 * 10 // 10 minutes
3679
- /** A random 6-digit one-time code. */
3680
- export function otpCode(): string {
3681
- return String(crypto.getRandomValues(new Uint32Array(1))[0]! % 1000000).padStart(6, '0')
3682
- }
3683
- async function codeHash(email: string, code: string): Promise<string> {
3684
- const d = await crypto.subtle.digest('SHA-256', enc.encode(email + ':' + code))
3685
- return b64url(d)
3686
- }
3687
- /** A signed pending-2FA token binding an email to a hashed code (the code itself is emailed). */
3688
- export async function signChallenge(email: string, code: string): Promise<string> {
3689
- const body = b64url(enc.encode(JSON.stringify({ email, ch: await codeHash(email, code), exp: Math.floor(Date.now() / 1000) + TFA_MAX_AGE, k: '2fa' })))
3690
- return body + '.' + (await hmac(body))
3691
- }
3692
- /** Verify a submitted code against the pending token; returns the email or null. */
3693
- export async function readChallenge(token: string | undefined, code: string): Promise<string | null> {
3694
- if (!token) return null
3695
- const dot = token.lastIndexOf('.')
3696
- if (dot < 0) return null
3697
- const body = token.slice(0, dot)
3698
- if (!timingSafeEqual(token.slice(dot + 1), await hmac(body))) return null
3699
- try {
3700
- const p = JSON.parse(new TextDecoder().decode(fromB64url(body))) as { email: string; ch: string; exp: number; k: string }
3701
- if (p.k !== '2fa' || typeof p.exp !== 'number' || p.exp * 1000 < Date.now()) return null
3702
- if (!timingSafeEqual(p.ch, await codeHash(p.email, code))) return null
3703
- return p.email
3704
- } catch {
3705
- return null
3706
- }
3707
- }
3606
+ const authTs = `// Regenerated by SvGrid Studio. Dependency-free auth: PBKDF2 password hashing +
3607
+ // stateless HMAC-signed session cookies (Web Crypto - runs on Node and edge runtimes).
3608
+ import { env } from '$env/dynamic/private'
3609
+
3610
+ export const SESSION_COOKIE = 'sv_session'
3611
+ export const SESSION_MAX_AGE = 60 * 60 * 24 * 7 // 7 days (seconds)
3612
+
3613
+ export type SessionUser = { email: string; name: string; role: string }
3614
+
3615
+ const enc = new TextEncoder()
3616
+ // Signing secret. Set SESSION_SECRET in the environment for production (see .env.example).
3617
+ const secret = () => env.SESSION_SECRET || 'dev-insecure-secret-change-me'
3618
+
3619
+ function b64url(bytes: ArrayBuffer | Uint8Array): string {
3620
+ const b = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
3621
+ let s = ''
3622
+ for (const byte of b) s += String.fromCharCode(byte)
3623
+ return btoa(s).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')
3624
+ }
3625
+ function fromB64url(s: string): Uint8Array {
3626
+ const p = s.replace(/-/g, '+').replace(/_/g, '/')
3627
+ const bin = atob(p + '==='.slice((p.length + 3) % 4))
3628
+ const out = new Uint8Array(bin.length)
3629
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
3630
+ return out
3631
+ }
3632
+ function timingSafeEqual(a: string, b: string): boolean {
3633
+ if (a.length !== b.length) return false
3634
+ let out = 0
3635
+ for (let i = 0; i < a.length; i++) out |= a.charCodeAt(i) ^ b.charCodeAt(i)
3636
+ return out === 0
3637
+ }
3638
+ async function hmac(data: string): Promise<string> {
3639
+ const key = await crypto.subtle.importKey('raw', enc.encode(secret()), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
3640
+ return b64url(await crypto.subtle.sign('HMAC', key, enc.encode(data)))
3641
+ }
3642
+
3643
+ /** Sign a stateless session token: base64url(payload).hmac(payload). */
3644
+ export async function signSession(user: SessionUser): Promise<string> {
3645
+ const payload = { ...user, exp: Math.floor(Date.now() / 1000) + SESSION_MAX_AGE }
3646
+ const body = b64url(enc.encode(JSON.stringify(payload)))
3647
+ return body + '.' + (await hmac(body))
3648
+ }
3649
+ /** Verify a token; returns the user, or null if missing / tampered / expired. */
3650
+ export async function readSession(token: string | undefined): Promise<SessionUser | null> {
3651
+ if (!token) return null
3652
+ const dot = token.lastIndexOf('.')
3653
+ if (dot < 0) return null
3654
+ const body = token.slice(0, dot)
3655
+ const sig = token.slice(dot + 1)
3656
+ if (!timingSafeEqual(sig, await hmac(body))) return null
3657
+ try {
3658
+ const p = JSON.parse(new TextDecoder().decode(fromB64url(body))) as SessionUser & { exp: number }
3659
+ if (typeof p.exp !== 'number' || p.exp * 1000 < Date.now()) return null
3660
+ return { email: p.email, name: p.name, role: p.role }
3661
+ } catch {
3662
+ return null
3663
+ }
3664
+ }
3665
+
3666
+ // ---- password hashing (PBKDF2) --------------------------------------------
3667
+ // Use these to store hashed passwords in a real user store: keep hashPassword()'s
3668
+ // output as \`passwordHash\`, then check with verifyPassword(input, passwordHash).
3669
+ export async function hashPassword(password: string, salt?: string): Promise<string> {
3670
+ const s = salt ?? b64url(crypto.getRandomValues(new Uint8Array(16)))
3671
+ const key = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveBits'])
3672
+ const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt: enc.encode(s), iterations: 100000, hash: 'SHA-256' }, key, 256)
3673
+ return s + ':' + b64url(bits)
3674
+ }
3675
+ export async function verifyPassword(password: string, stored: string): Promise<boolean> {
3676
+ const salt = stored.split(':')[0]
3677
+ if (!salt) return false
3678
+ return timingSafeEqual(await hashPassword(password, salt), stored)
3679
+ }
3680
+
3681
+ // ---- password reset (stateless, signed link) ------------------------------
3682
+ const RESET_MAX_AGE = 60 * 30 // 30 minutes
3683
+ /** A short-lived, signed password-reset token for an email (no DB row needed). */
3684
+ export async function signReset(email: string): Promise<string> {
3685
+ const body = b64url(enc.encode(JSON.stringify({ email, exp: Math.floor(Date.now() / 1000) + RESET_MAX_AGE, k: 'reset' })))
3686
+ return body + '.' + (await hmac(body))
3687
+ }
3688
+ /** Verify a reset token; returns the email, or null if invalid / expired. */
3689
+ export async function readReset(token: string | undefined): Promise<string | null> {
3690
+ if (!token) return null
3691
+ const dot = token.lastIndexOf('.')
3692
+ if (dot < 0) return null
3693
+ const body = token.slice(0, dot)
3694
+ if (!timingSafeEqual(token.slice(dot + 1), await hmac(body))) return null
3695
+ try {
3696
+ const p = JSON.parse(new TextDecoder().decode(fromB64url(body))) as { email: string; exp: number; k: string }
3697
+ if (p.k !== 'reset' || typeof p.exp !== 'number' || p.exp * 1000 < Date.now()) return null
3698
+ return p.email
3699
+ } catch {
3700
+ return null
3701
+ }
3702
+ }
3703
+
3704
+ /** Deliver a password-reset link. STUB: logs it in dev - wire your email provider
3705
+ * (Resend / SendGrid / Postmark / SMTP) here for production. */
3706
+ export async function sendResetEmail(email: string, link: string): Promise<void> {
3707
+ console.log('[auth] password reset for ' + email + ' -> ' + link)
3708
+ }
3709
+ ${twoFactor ? `
3710
+ // ---- email two-factor challenge (stateless, signed) -----------------------
3711
+ export const TFA_COOKIE = 'sv_2fa'
3712
+ const TFA_MAX_AGE = 60 * 10 // 10 minutes
3713
+ /** A random 6-digit one-time code. */
3714
+ export function otpCode(): string {
3715
+ return String(crypto.getRandomValues(new Uint32Array(1))[0]! % 1000000).padStart(6, '0')
3716
+ }
3717
+ async function codeHash(email: string, code: string): Promise<string> {
3718
+ const d = await crypto.subtle.digest('SHA-256', enc.encode(email + ':' + code))
3719
+ return b64url(d)
3720
+ }
3721
+ /** A signed pending-2FA token binding an email to a hashed code (the code itself is emailed). */
3722
+ export async function signChallenge(email: string, code: string): Promise<string> {
3723
+ const body = b64url(enc.encode(JSON.stringify({ email, ch: await codeHash(email, code), exp: Math.floor(Date.now() / 1000) + TFA_MAX_AGE, k: '2fa' })))
3724
+ return body + '.' + (await hmac(body))
3725
+ }
3726
+ /** Verify a submitted code against the pending token; returns the email or null. */
3727
+ export async function readChallenge(token: string | undefined, code: string): Promise<string | null> {
3728
+ if (!token) return null
3729
+ const dot = token.lastIndexOf('.')
3730
+ if (dot < 0) return null
3731
+ const body = token.slice(0, dot)
3732
+ if (!timingSafeEqual(token.slice(dot + 1), await hmac(body))) return null
3733
+ try {
3734
+ const p = JSON.parse(new TextDecoder().decode(fromB64url(body))) as { email: string; ch: string; exp: number; k: string }
3735
+ if (p.k !== '2fa' || typeof p.exp !== 'number' || p.exp * 1000 < Date.now()) return null
3736
+ if (!timingSafeEqual(p.ch, await codeHash(p.email, code))) return null
3737
+ return p.email
3738
+ } catch {
3739
+ return null
3740
+ }
3741
+ }
3708
3742
  ` : ''}`;
3709
3743
  const usersTs = dbBacked
3710
- ? `// Regenerated by SvGrid Studio. DB-backed user store: reads the \`auth_users\` table
3711
- // (Drizzle) with hashed passwords. The demo users below are seeded ONCE, on the first
3712
- // login, if the table is empty - delete the seed for production, or manage users in the DB.
3713
- import { eq } from 'drizzle-orm'
3714
- import { db } from './db/index'
3715
- import { authUsers, type AuthUserRow } from './db/schema'
3716
- import { hashPassword } from './auth'
3717
-
3718
- export type AppUser = AuthUserRow
3719
-
3720
- const SEED: Array<{ email: string; name: string; role: string; password: string }> = [
3721
- ${seedLiteral},
3722
- ]
3723
-
3724
- // Seed the demo users once (idempotent + concurrency-safe: the unique email index
3725
- // rejects a racing duplicate, which we swallow). Cached per process after the first run.
3726
- let seeding: Promise<void> | null = null
3727
- function ensureSeeded(): Promise<void> {
3728
- if (!seeding) seeding = (async () => {
3729
- const existing = await db.select({ email: authUsers.email }).from(authUsers).limit(1)
3730
- if (existing.length) return
3731
- for (const u of SEED) {
3732
- try {
3733
- await db.insert(authUsers).values({ email: u.email, name: u.name, role: u.role, passwordHash: await hashPassword(u.password) })
3734
- } catch { /* unique-email race: another request seeded it first */ }
3735
- }
3736
- })()
3737
- return seeding
3738
- }
3739
-
3740
- export async function findUser(email: string): Promise<AppUser | undefined> {
3741
- await ensureSeeded()
3742
- const e = email.trim().toLowerCase()
3743
- return (await db.select().from(authUsers).where(eq(authUsers.email, e))).at(0)
3744
- }
3745
-
3746
- /** Public view of a user (no password hash) - for the admin list. */
3747
- export type PublicUser = { email: string; name: string; role: string }
3748
- export async function listUsers(): Promise<PublicUser[]> {
3749
- await ensureSeeded()
3750
- return db.select({ email: authUsers.email, name: authUsers.name, role: authUsers.role }).from(authUsers)
3751
- }
3752
-
3753
- /** Create a user (hashed password). Returns undefined if the email already exists. */
3754
- export async function createUser(input: { email: string; name: string; password: string; role: string }): Promise<AppUser | undefined> {
3755
- const email = input.email.trim().toLowerCase()
3756
- if (await findUser(email)) return undefined
3757
- const passwordHash = await hashPassword(input.password)
3758
- try {
3759
- await db.insert(authUsers).values({ email, name: input.name, role: input.role, passwordHash })
3760
- } catch {
3761
- return undefined // unique-email race
3762
- }
3763
- return findUser(email)
3764
- }
3765
-
3766
- export async function updatePassword(email: string, newPassword: string): Promise<void> {
3767
- await db.update(authUsers).set({ passwordHash: await hashPassword(newPassword) }).where(eq(authUsers.email, email.trim().toLowerCase()))
3768
- }
3769
- export async function setUserRole(email: string, role: string): Promise<void> {
3770
- await db.update(authUsers).set({ role }).where(eq(authUsers.email, email.trim().toLowerCase()))
3771
- }
3772
- export async function deleteUser(email: string): Promise<void> {
3773
- await db.delete(authUsers).where(eq(authUsers.email, email.trim().toLowerCase()))
3774
- }${twoFactor ? `
3775
- export async function setTwoFactor(email: string, on: boolean): Promise<void> {
3776
- await db.update(authUsers).set({ twoFactor: on }).where(eq(authUsers.email, email.trim().toLowerCase()))
3777
- }` : ''}
3744
+ ? `// Regenerated by SvGrid Studio. DB-backed user store: reads the \`auth_users\` table
3745
+ // (Drizzle) with hashed passwords. The demo users below are seeded ONCE, on the first
3746
+ // login, if the table is empty - delete the seed for production, or manage users in the DB.
3747
+ import { eq } from 'drizzle-orm'
3748
+ import { db } from './db/index'
3749
+ import { authUsers, type AuthUserRow } from './db/schema'
3750
+ import { hashPassword } from './auth'
3751
+
3752
+ export type AppUser = AuthUserRow
3753
+
3754
+ const SEED: Array<{ email: string; name: string; role: string; password: string }> = [
3755
+ ${seedLiteral},
3756
+ ]
3757
+
3758
+ // Seed the demo users once (idempotent + concurrency-safe: the unique email index
3759
+ // rejects a racing duplicate, which we swallow). Cached per process after the first run.
3760
+ let seeding: Promise<void> | null = null
3761
+ function ensureSeeded(): Promise<void> {
3762
+ if (!seeding) seeding = (async () => {
3763
+ const existing = await db.select({ email: authUsers.email }).from(authUsers).limit(1)
3764
+ if (existing.length) return
3765
+ for (const u of SEED) {
3766
+ try {
3767
+ await db.insert(authUsers).values({ email: u.email, name: u.name, role: u.role, passwordHash: await hashPassword(u.password) })
3768
+ } catch { /* unique-email race: another request seeded it first */ }
3769
+ }
3770
+ })()
3771
+ return seeding
3772
+ }
3773
+
3774
+ export async function findUser(email: string): Promise<AppUser | undefined> {
3775
+ await ensureSeeded()
3776
+ const e = email.trim().toLowerCase()
3777
+ return (await db.select().from(authUsers).where(eq(authUsers.email, e))).at(0)
3778
+ }
3779
+
3780
+ /** Public view of a user (no password hash) - for the admin list. */
3781
+ export type PublicUser = { email: string; name: string; role: string }
3782
+ export async function listUsers(): Promise<PublicUser[]> {
3783
+ await ensureSeeded()
3784
+ return db.select({ email: authUsers.email, name: authUsers.name, role: authUsers.role }).from(authUsers)
3785
+ }
3786
+
3787
+ /** Create a user (hashed password). Returns undefined if the email already exists. */
3788
+ export async function createUser(input: { email: string; name: string; password: string; role: string }): Promise<AppUser | undefined> {
3789
+ const email = input.email.trim().toLowerCase()
3790
+ if (await findUser(email)) return undefined
3791
+ const passwordHash = await hashPassword(input.password)
3792
+ try {
3793
+ await db.insert(authUsers).values({ email, name: input.name, role: input.role, passwordHash })
3794
+ } catch {
3795
+ return undefined // unique-email race
3796
+ }
3797
+ return findUser(email)
3798
+ }
3799
+
3800
+ export async function updatePassword(email: string, newPassword: string): Promise<void> {
3801
+ await db.update(authUsers).set({ passwordHash: await hashPassword(newPassword) }).where(eq(authUsers.email, email.trim().toLowerCase()))
3802
+ }
3803
+ export async function setUserRole(email: string, role: string): Promise<void> {
3804
+ await db.update(authUsers).set({ role }).where(eq(authUsers.email, email.trim().toLowerCase()))
3805
+ }
3806
+ export async function deleteUser(email: string): Promise<void> {
3807
+ await db.delete(authUsers).where(eq(authUsers.email, email.trim().toLowerCase()))
3808
+ }${twoFactor ? `
3809
+ export async function setTwoFactor(email: string, on: boolean): Promise<void> {
3810
+ await db.update(authUsers).set({ twoFactor: on }).where(eq(authUsers.email, email.trim().toLowerCase()))
3811
+ }` : ''}
3778
3812
  `
3779
- : `// Regenerated by SvGrid Studio. DEMO user store - replace with your own (a DB table,
3780
- // an external identity provider, ...). The seed passwords below are demo defaults
3781
- // (printed on the login page); they are HASHED (PBKDF2) at startup and never compared
3782
- // in plaintext. Change them for anything real. Tip: turn on the Drizzle data layer and
3783
- // this becomes a real \`auth_users\` DB table.
3784
- import { hashPassword } from './auth'
3785
-
3786
- export type AppUser = { email: string; name: string; role: string; passwordHash: string }
3787
-
3788
- const SEED: Array<{ email: string; name: string; role: string; password: string }> = [
3789
- ${usersLiteral},
3790
- ]
3791
-
3792
- // Hash the seed passwords once at module load (top-level await) so the store holds
3793
- // only PBKDF2 hashes - login verifies with verifyPassword(), never a plaintext compare.
3794
- export const USERS: AppUser[] = await Promise.all(
3795
- SEED.map(async (u) => ({ email: u.email, name: u.name, role: u.role, passwordHash: await hashPassword(u.password) })),
3796
- )
3797
-
3798
- export function findUser(email: string): AppUser | undefined {
3799
- const e = email.trim().toLowerCase()
3800
- return USERS.find((u) => u.email.toLowerCase() === e)
3801
- }
3813
+ : `// Regenerated by SvGrid Studio. DEMO user store - replace with your own (a DB table,
3814
+ // an external identity provider, ...). The seed passwords below are demo defaults
3815
+ // (printed on the login page); they are HASHED (PBKDF2) at startup and never compared
3816
+ // in plaintext. Change them for anything real. Tip: turn on the Drizzle data layer and
3817
+ // this becomes a real \`auth_users\` DB table.
3818
+ import { hashPassword } from './auth'
3819
+
3820
+ export type AppUser = { email: string; name: string; role: string; passwordHash: string }
3821
+
3822
+ const SEED: Array<{ email: string; name: string; role: string; password: string }> = [
3823
+ ${usersLiteral},
3824
+ ]
3825
+
3826
+ // Hash the seed passwords once at module load (top-level await) so the store holds
3827
+ // only PBKDF2 hashes - login verifies with verifyPassword(), never a plaintext compare.
3828
+ export const USERS: AppUser[] = await Promise.all(
3829
+ SEED.map(async (u) => ({ email: u.email, name: u.name, role: u.role, passwordHash: await hashPassword(u.password) })),
3830
+ )
3831
+
3832
+ export function findUser(email: string): AppUser | undefined {
3833
+ const e = email.trim().toLowerCase()
3834
+ return USERS.find((u) => u.email.toLowerCase() === e)
3835
+ }
3802
3836
  `;
3803
- const hooksTs = `import type { Handle } from '@sveltejs/kit'
3804
- import { SESSION_COOKIE, readSession } from '../lib/server/auth'
3805
-
3806
- // Resolve the signed session on every request into event.locals - the RBAC layer
3807
- // (getServerRole / authorize) and the app shell read event.locals.role from here.
3808
- export const handle: Handle = async ({ event, resolve }) => {
3809
- const user = await readSession(event.cookies.get(SESSION_COOKIE))
3810
- event.locals.user = user ?? undefined
3811
- event.locals.role = user?.role${tenantColumn ? `
3812
- // Multi-tenancy: the tenant travels on the session, so every scoped API route
3813
- // reads it from here rather than trusting anything the client sends.
3814
- event.locals.${tenantColumn} = (user as { ${tenantColumn}?: string } | null | undefined)?.${tenantColumn}` : ''}
3815
- return resolve(event)
3816
- }
3837
+ const hooksTs = `import type { Handle } from '@sveltejs/kit'
3838
+ import { SESSION_COOKIE, readSession } from '../lib/server/auth'
3839
+
3840
+ // Resolve the signed session on every request into event.locals - the RBAC layer
3841
+ // (getServerRole / authorize) and the app shell read event.locals.role from here.
3842
+ export const handle: Handle = async ({ event, resolve }) => {
3843
+ const user = await readSession(event.cookies.get(SESSION_COOKIE))
3844
+ event.locals.user = user ?? undefined
3845
+ event.locals.role = user?.role${tenantColumn ? `
3846
+ // Multi-tenancy: the tenant travels on the session, so every scoped API route
3847
+ // reads it from here rather than trusting anything the client sends.
3848
+ event.locals.${tenantColumn} = (user as { ${tenantColumn}?: string } | null | undefined)?.${tenantColumn}` : ''}
3849
+ return resolve(event)
3850
+ }
3817
3851
  `;
3818
- const localsDts = `import type { SessionUser } from '../lib/server/auth'
3819
-
3820
- declare global {
3821
- namespace App {
3822
- interface Locals {
3823
- user?: SessionUser
3824
- role?: string
3825
- }
3826
- }
3827
- }
3828
-
3829
- export {}
3852
+ const localsDts = `import type { SessionUser } from '../lib/server/auth'
3853
+
3854
+ declare global {
3855
+ namespace App {
3856
+ interface Locals {
3857
+ user?: SessionUser
3858
+ role?: string
3859
+ }
3860
+ }
3861
+ }
3862
+
3863
+ export {}
3830
3864
  `;
3831
3865
  // Routes that render bare (no app shell) and don't require a session.
3832
3866
  const publicRoutes = ['/login', ...(twoFactor ? ['/login/verify'] : []), ...(register ? ['/register', '/forgot-password', '/reset-password'] : [])];
3833
- const layoutServerTs = `import type { LayoutServerLoad } from './$types'
3834
- ${protect ? `import { redirect } from '@sveltejs/kit'\n\nconst PUBLIC = new Set(${JSON.stringify(publicRoutes)})\n` : ''}
3835
- // Expose the signed-in user + role to every page (read as \`data.user\` / \`data.role\`,
3836
- // or \`page.data\`).${protect ? ' Unauthenticated visitors are sent to /login.' : ''}
3837
- export const load: LayoutServerLoad = async ({ locals${protect ? ', url' : ''} }) => {
3838
- ${protect ? " if (!locals.user && !PUBLIC.has(url.pathname)) throw redirect(302, '/login?redirectTo=' + encodeURIComponent(url.pathname))\n" : ''} return { user: locals.user ?? null, role: locals.role ?? null }
3839
- }
3867
+ const layoutServerTs = `import type { LayoutServerLoad } from './$types'
3868
+ ${protect ? `import { redirect } from '@sveltejs/kit'\n\nconst PUBLIC = new Set(${JSON.stringify(publicRoutes)})\n` : ''}
3869
+ // Expose the signed-in user + role to every page (read as \`data.user\` / \`data.role\`,
3870
+ // or \`page.data\`).${protect ? ' Unauthenticated visitors are sent to /login.' : ''}
3871
+ export const load: LayoutServerLoad = async ({ locals${protect ? ', url' : ''} }) => {
3872
+ ${protect ? " if (!locals.user && !PUBLIC.has(url.pathname)) throw redirect(302, '/login?redirectTo=' + encodeURIComponent(url.pathname))\n" : ''} return { user: locals.user ?? null, role: locals.role ?? null }
3873
+ }
3840
3874
  `;
3841
3875
  const loginCheck = dbBacked
3842
- ? `const user = await findUser(email)
3843
- // DB-backed store: verify against the stored PBKDF2 hash.
3876
+ ? `const user = await findUser(email)
3877
+ // DB-backed store: verify against the stored PBKDF2 hash.
3844
3878
  if (!user || !(await verifyPassword(password, user.passwordHash))) return fail(401, { email, error: 'Invalid email or password.' })`
3845
- : `const user = findUser(email)
3846
- // Verify against the PBKDF2 hash of the seed (users store hashes it at startup).
3879
+ : `const user = findUser(email)
3880
+ // Verify against the PBKDF2 hash of the seed (users store hashes it at startup).
3847
3881
  if (!user || !(await verifyPassword(password, user.passwordHash))) return fail(401, { email, error: 'Invalid email or password.' })`;
3848
3882
  // 2FA (email code): after the password check, email a code + park a pending token,
3849
3883
  // then send the user to /login/verify instead of issuing the session immediately.
3850
- const twoFactorBranch = twoFactor ? `
3851
- if (user.twoFactor) {
3852
- const code = otpCode()
3853
- await sendEmail(user.email, 'Your verification code', '<p>Your sign-in code is <strong>' + code + '</strong>. It expires in 10 minutes.</p>')
3854
- cookies.set(TFA_COOKIE, await signChallenge(user.email, code), { path: '/', httpOnly: true, sameSite: 'lax', secure: url.hostname !== 'localhost' && url.hostname !== '127.0.0.1', maxAge: 600 })
3855
- const rt = url.searchParams.get('redirectTo')
3856
- throw redirect(302, '/login/verify' + (rt ? '?redirectTo=' + encodeURIComponent(rt) : ''))
3884
+ const twoFactorBranch = twoFactor ? `
3885
+ if (user.twoFactor) {
3886
+ const code = otpCode()
3887
+ await sendEmail(user.email, 'Your verification code', '<p>Your sign-in code is <strong>' + code + '</strong>. It expires in 10 minutes.</p>')
3888
+ cookies.set(TFA_COOKIE, await signChallenge(user.email, code), { path: '/', httpOnly: true, sameSite: 'lax', secure: url.hostname !== 'localhost' && url.hostname !== '127.0.0.1', maxAge: 600 })
3889
+ const rt = url.searchParams.get('redirectTo')
3890
+ throw redirect(302, '/login/verify' + (rt ? '?redirectTo=' + encodeURIComponent(rt) : ''))
3857
3891
  }` : '';
3858
3892
  const loginImports = twoFactor
3859
3893
  ? "import { SESSION_COOKIE, SESSION_MAX_AGE, TFA_COOKIE, signSession, verifyPassword, otpCode, signChallenge } from '../lib/server/auth'\nimport { findUser } from '$lib/server/users'\nimport { sendEmail } from '$lib/server/email'"
3860
3894
  : `import { SESSION_COOKIE, SESSION_MAX_AGE, signSession, verifyPassword } from '../lib/server/auth'\nimport { findUser } from '$lib/server/users'`;
3861
- const loginServerTs = `import { fail, redirect } from '@sveltejs/kit'
3862
- import type { Actions, PageServerLoad } from './$types'
3863
- ${loginImports}
3864
-
3865
- export const load: PageServerLoad = async ({ locals }) => {
3866
- if (locals.user) throw redirect(302, '/')
3867
- return {}
3868
- }
3869
-
3870
- export const actions: Actions = {
3871
- default: async ({ request, cookies, url }) => {
3872
- const form = await request.formData()
3873
- const email = String(form.get('email') ?? '')
3874
- const password = String(form.get('password') ?? '')
3875
- ${loginCheck}${twoFactorBranch}
3876
- const token = await signSession({ email: user.email, name: user.name, role: user.role })
3877
- cookies.set(SESSION_COOKIE, token, {
3878
- path: '/', httpOnly: true, sameSite: 'lax',
3879
- secure: url.hostname !== 'localhost' && url.hostname !== '127.0.0.1',
3880
- maxAge: SESSION_MAX_AGE,
3881
- })
3882
- throw redirect(302, url.searchParams.get('redirectTo') || '/')
3883
- },
3884
- }
3895
+ const loginServerTs = `import { fail, redirect } from '@sveltejs/kit'
3896
+ import type { Actions, PageServerLoad } from './$types'
3897
+ ${loginImports}
3898
+
3899
+ export const load: PageServerLoad = async ({ locals }) => {
3900
+ if (locals.user) throw redirect(302, '/')
3901
+ return {}
3902
+ }
3903
+
3904
+ export const actions: Actions = {
3905
+ default: async ({ request, cookies, url }) => {
3906
+ const form = await request.formData()
3907
+ const email = String(form.get('email') ?? '')
3908
+ const password = String(form.get('password') ?? '')
3909
+ ${loginCheck}${twoFactorBranch}
3910
+ const token = await signSession({ email: user.email, name: user.name, role: user.role })
3911
+ cookies.set(SESSION_COOKIE, token, {
3912
+ path: '/', httpOnly: true, sameSite: 'lax',
3913
+ secure: url.hostname !== 'localhost' && url.hostname !== '127.0.0.1',
3914
+ maxAge: SESSION_MAX_AGE,
3915
+ })
3916
+ throw redirect(302, url.searchParams.get('redirectTo') || '/')
3917
+ },
3918
+ }
3885
3919
  `;
3886
3920
  // Shared full-screen auth-page styles (login / register / forgot / reset).
3887
- const authStyle = `<style>
3888
- .auth { display: grid; place-items: center; min-height: 100vh; padding: 24px; background: var(--sg-bg-subtle, #f8fafc); }
3889
- .auth__card { width: 100%; max-width: 360px; display: flex; flex-direction: column; gap: 12px; padding: 28px; background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 16px; box-shadow: 0 12px 40px -12px rgba(15, 23, 42, 0.18); }
3890
- .auth__title { margin: 0; font-size: 22px; font-weight: 720; letter-spacing: -0.02em; }
3891
- .auth__sub { margin: -6px 0 6px; font-size: 13.5px; color: var(--sg-muted, #64748b); }
3892
- .auth__field { display: flex; flex-direction: column; gap: 5px; font-size: 12.5px; font-weight: 600; color: var(--sg-muted, #64748b); }
3893
- .auth__field input { padding: 9px 11px; font: inherit; font-size: 14px; font-weight: 400; color: var(--sg-fg, #0f172a); background: var(--sg-input-bg, #fff); border: 1px solid var(--sg-input-border, #e6e8ec); border-radius: 9px; }
3894
- .auth__field input:focus { outline: none; border-color: var(--sg-accent, #6366f1); box-shadow: 0 0 0 3px color-mix(in srgb, var(--sg-accent, #6366f1) 18%, transparent); }
3895
- .auth__btn { margin-top: 4px; padding: 10px; font: inherit; font-size: 14px; font-weight: 640; color: var(--sg-on-accent, #fff); background: var(--sg-accent, #6366f1); border: none; border-radius: 9px; cursor: pointer; }
3896
- .auth__btn:hover { filter: brightness(1.06); }
3897
- .auth__err { margin: 0; padding: 8px 11px; font-size: 13px; color: var(--sg-danger, #b3261e); background: color-mix(in srgb, var(--sg-danger, #dc2626) 9%, var(--sg-bg, #fff)); border: 1px solid color-mix(in srgb, var(--sg-danger, #dc2626) 35%, var(--sg-border, #e6e8ec)); border-radius: 9px; }
3898
- .auth__ok { margin: 0; padding: 8px 11px; font-size: 13px; color: #166534; background: color-mix(in srgb, #16a34a 9%, var(--sg-bg, #fff)); border: 1px solid color-mix(in srgb, #16a34a 35%, var(--sg-border, #e6e8ec)); border-radius: 9px; }
3899
- .auth__hint { margin: 6px 0 0; font-size: 12px; color: var(--sg-muted, #94a3b8); text-align: center; }
3900
- .auth__hint code { background: color-mix(in srgb, var(--sg-fg, #0f172a) 6%, transparent); padding: 1px 5px; border-radius: 5px; }
3901
- .auth__links { margin: 2px 0 0; display: flex; justify-content: space-between; font-size: 12.5px; }
3902
- .auth__links a { color: var(--sg-accent, #6366f1); text-decoration: none; font-weight: 600; }
3903
- .auth__or { display: flex; align-items: center; gap: 10px; margin: 2px 0; color: var(--sg-muted, #94a3b8); font-size: 12px; }
3904
- .auth__or::before, .auth__or::after { content: ''; flex: 1; height: 1px; background: var(--sg-border, #e6e8ec); }
3905
- .auth__oauth { display: flex; flex-direction: column; gap: 8px; }
3906
- .auth__oauth-btn { display: block; text-align: center; padding: 9px; font-size: 13.5px; font-weight: 600; color: var(--sg-fg, #0f172a); background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 9px; text-decoration: none; }
3907
- .auth__oauth-btn:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 4%, var(--sg-bg, #fff)); }
3921
+ const authStyle = `<style>
3922
+ .auth { display: grid; place-items: center; min-height: 100vh; padding: 24px; background: var(--sg-bg-subtle, #f8fafc); }
3923
+ .auth__card { width: 100%; max-width: 360px; display: flex; flex-direction: column; gap: 12px; padding: 28px; background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 16px; box-shadow: 0 12px 40px -12px rgba(15, 23, 42, 0.18); }
3924
+ .auth__title { margin: 0; font-size: 22px; font-weight: 720; letter-spacing: -0.02em; }
3925
+ .auth__sub { margin: -6px 0 6px; font-size: 13.5px; color: var(--sg-muted, #64748b); }
3926
+ .auth__field { display: flex; flex-direction: column; gap: 5px; font-size: 12.5px; font-weight: 600; color: var(--sg-muted, #64748b); }
3927
+ .auth__field input { padding: 9px 11px; font: inherit; font-size: 14px; font-weight: 400; color: var(--sg-fg, #0f172a); background: var(--sg-input-bg, #fff); border: 1px solid var(--sg-input-border, #e6e8ec); border-radius: 9px; }
3928
+ .auth__field input:focus { outline: none; border-color: var(--sg-accent, #6366f1); box-shadow: 0 0 0 3px color-mix(in srgb, var(--sg-accent, #6366f1) 18%, transparent); }
3929
+ .auth__btn { margin-top: 4px; padding: 10px; font: inherit; font-size: 14px; font-weight: 640; color: var(--sg-on-accent, #fff); background: var(--sg-accent, #6366f1); border: none; border-radius: 9px; cursor: pointer; }
3930
+ .auth__btn:hover { filter: brightness(1.06); }
3931
+ .auth__err { margin: 0; padding: 8px 11px; font-size: 13px; color: var(--sg-danger, #b3261e); background: color-mix(in srgb, var(--sg-danger, #dc2626) 9%, var(--sg-bg, #fff)); border: 1px solid color-mix(in srgb, var(--sg-danger, #dc2626) 35%, var(--sg-border, #e6e8ec)); border-radius: 9px; }
3932
+ .auth__ok { margin: 0; padding: 8px 11px; font-size: 13px; color: #166534; background: color-mix(in srgb, #16a34a 9%, var(--sg-bg, #fff)); border: 1px solid color-mix(in srgb, #16a34a 35%, var(--sg-border, #e6e8ec)); border-radius: 9px; }
3933
+ .auth__hint { margin: 6px 0 0; font-size: 12px; color: var(--sg-muted, #94a3b8); text-align: center; }
3934
+ .auth__hint code { background: color-mix(in srgb, var(--sg-fg, #0f172a) 6%, transparent); padding: 1px 5px; border-radius: 5px; }
3935
+ .auth__links { margin: 2px 0 0; display: flex; justify-content: space-between; font-size: 12.5px; }
3936
+ .auth__links a { color: var(--sg-accent, #6366f1); text-decoration: none; font-weight: 600; }
3937
+ .auth__or { display: flex; align-items: center; gap: 10px; margin: 2px 0; color: var(--sg-muted, #94a3b8); font-size: 12px; }
3938
+ .auth__or::before, .auth__or::after { content: ''; flex: 1; height: 1px; background: var(--sg-border, #e6e8ec); }
3939
+ .auth__oauth { display: flex; flex-direction: column; gap: 8px; }
3940
+ .auth__oauth-btn { display: block; text-align: center; padding: 9px; font-size: 13.5px; font-weight: 600; color: var(--sg-fg, #0f172a); background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 9px; text-decoration: none; }
3941
+ .auth__oauth-btn:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 4%, var(--sg-bg, #fff)); }
3908
3942
  </style>`;
3909
- const loginPage = `<script lang="ts">
3910
- import { enhance } from '$app/forms'
3911
- let { form } = $props()
3912
- </script>
3913
-
3914
- <div class="auth">
3915
- <form method="POST" use:enhance class="auth__card">
3916
- <h1 class="auth__title">Sign in</h1>
3917
- <p class="auth__sub">${jsStrHtml(project.title || 'Welcome back')}</p>
3918
- {#if form?.error}<p class="auth__err" role="alert">{form.error}</p>{/if}
3919
- <label class="auth__field"><span>Email</span>
3920
- <input name="email" type="email" autocomplete="username" value={form?.email ?? ''} required />
3921
- </label>
3922
- <label class="auth__field"><span>Password</span>
3923
- <input name="password" type="password" autocomplete="current-password" required />
3924
- </label>
3925
- <button class="auth__btn" type="submit">Sign in</button>${oauthProviders.length ? `
3926
- <div class="auth__or"><span>or</span></div>
3927
- <div class="auth__oauth">
3928
- ${oauthProviders.map((p) => `<a class="auth__oauth-btn" href="/auth/${p}">Continue with ${{ github: 'GitHub', google: 'Google', oidc: 'SSO' }[p]}</a>`).join('\n ')}
3929
- </div>` : ''}
3930
- <p class="auth__hint">Demo account: <code>${demo.email}</code> / <code>${demo.password}</code></p>${register ? `
3931
- <p class="auth__links"><a href="/forgot-password">Forgot password?</a><a href="/register">Create account</a></p>` : ''}
3932
- </form>
3933
- </div>
3934
-
3935
- ${authStyle}
3943
+ const loginPage = `<script lang="ts">
3944
+ import { enhance } from '$app/forms'
3945
+ let { form } = $props()
3946
+ </script>
3947
+
3948
+ <div class="auth">
3949
+ <form method="POST" use:enhance class="auth__card">
3950
+ <h1 class="auth__title">Sign in</h1>
3951
+ <p class="auth__sub">${jsStrHtml(project.title || 'Welcome back')}</p>
3952
+ {#if form?.error}<p class="auth__err" role="alert">{form.error}</p>{/if}
3953
+ <label class="auth__field"><span>Email</span>
3954
+ <input name="email" type="email" autocomplete="username" value={form?.email ?? ''} required />
3955
+ </label>
3956
+ <label class="auth__field"><span>Password</span>
3957
+ <input name="password" type="password" autocomplete="current-password" required />
3958
+ </label>
3959
+ <button class="auth__btn" type="submit">Sign in</button>${oauthProviders.length ? `
3960
+ <div class="auth__or"><span>or</span></div>
3961
+ <div class="auth__oauth">
3962
+ ${oauthProviders.map((p) => `<a class="auth__oauth-btn" href="/auth/${p}">Continue with ${{ github: 'GitHub', google: 'Google', oidc: 'SSO' }[p]}</a>`).join('\n ')}
3963
+ </div>` : ''}
3964
+ <p class="auth__hint">Demo account: <code>${demo.email}</code> / <code>${demo.password}</code></p>${register ? `
3965
+ <p class="auth__links"><a href="/forgot-password">Forgot password?</a><a href="/register">Create account</a></p>` : ''}
3966
+ </form>
3967
+ </div>
3968
+
3969
+ ${authStyle}
3936
3970
  `;
3937
- const logoutServerTs = `import { redirect } from '@sveltejs/kit'
3938
- import type { Actions } from './$types'
3939
- import { SESSION_COOKIE } from '../lib/server/auth'
3940
-
3941
- export const actions: Actions = {
3942
- default: async ({ cookies }) => {
3943
- cookies.delete(SESSION_COOKIE, { path: '/' })
3944
- throw redirect(302, '/login')
3945
- },
3946
- }
3971
+ const logoutServerTs = `import { redirect } from '@sveltejs/kit'
3972
+ import type { Actions } from './$types'
3973
+ import { SESSION_COOKIE } from '../lib/server/auth'
3974
+
3975
+ export const actions: Actions = {
3976
+ default: async ({ cookies }) => {
3977
+ cookies.delete(SESSION_COOKIE, { path: '/' })
3978
+ throw redirect(302, '/login')
3979
+ },
3980
+ }
3947
3981
  `;
3948
3982
  // --- change password (any signed-in user; DB-backed only) ---
3949
- const accountServerTs = `import { fail, redirect } from '@sveltejs/kit'
3950
- import type { Actions, PageServerLoad } from './$types'
3951
- import { findUser, updatePassword${twoFactor ? ', setTwoFactor' : ''} } from '$lib/server/users'
3952
- import { verifyPassword } from '../lib/server/auth'
3953
-
3954
- export const load: PageServerLoad = async ({ locals }) => {
3955
- if (!locals.user) throw redirect(302, '/login')
3956
- ${twoFactor ? ` const me = await findUser(locals.user.email)
3957
- return { user: locals.user, twoFactor: me?.twoFactor ?? false }` : ' return { user: locals.user }'}
3958
- }
3959
-
3960
- export const actions: Actions = {
3961
- default: async ({ request, locals }) => {
3962
- if (!locals.user) throw redirect(302, '/login')
3963
- const form = await request.formData()
3964
- const current = String(form.get('current') ?? '')
3965
- const next = String(form.get('next') ?? '')
3966
- if (next.length < 8) return fail(400, { error: 'New password must be at least 8 characters.' })
3967
- const user = await findUser(locals.user.email)
3968
- if (!user || !(await verifyPassword(current, user.passwordHash))) return fail(401, { error: 'Current password is incorrect.' })
3969
- await updatePassword(user.email, next)
3970
- return { ok: true }
3971
- },${twoFactor ? `
3972
- tfa: async ({ request, locals }) => {
3973
- if (!locals.user) throw redirect(302, '/login')
3974
- const on = String((await request.formData()).get('on') ?? '') === 'true'
3975
- await setTwoFactor(locals.user.email, on)
3976
- return { tfa: on }
3977
- },` : ''}
3978
- }
3983
+ const accountServerTs = `import { fail, redirect } from '@sveltejs/kit'
3984
+ import type { Actions, PageServerLoad } from './$types'
3985
+ import { findUser, updatePassword${twoFactor ? ', setTwoFactor' : ''} } from '$lib/server/users'
3986
+ import { verifyPassword } from '../lib/server/auth'
3987
+
3988
+ export const load: PageServerLoad = async ({ locals }) => {
3989
+ if (!locals.user) throw redirect(302, '/login')
3990
+ ${twoFactor ? ` const me = await findUser(locals.user.email)
3991
+ return { user: locals.user, twoFactor: me?.twoFactor ?? false }` : ' return { user: locals.user }'}
3992
+ }
3993
+
3994
+ export const actions: Actions = {
3995
+ default: async ({ request, locals }) => {
3996
+ if (!locals.user) throw redirect(302, '/login')
3997
+ const form = await request.formData()
3998
+ const current = String(form.get('current') ?? '')
3999
+ const next = String(form.get('next') ?? '')
4000
+ if (next.length < 8) return fail(400, { error: 'New password must be at least 8 characters.' })
4001
+ const user = await findUser(locals.user.email)
4002
+ if (!user || !(await verifyPassword(current, user.passwordHash))) return fail(401, { error: 'Current password is incorrect.' })
4003
+ await updatePassword(user.email, next)
4004
+ return { ok: true }
4005
+ },${twoFactor ? `
4006
+ tfa: async ({ request, locals }) => {
4007
+ if (!locals.user) throw redirect(302, '/login')
4008
+ const on = String((await request.formData()).get('on') ?? '') === 'true'
4009
+ await setTwoFactor(locals.user.email, on)
4010
+ return { tfa: on }
4011
+ },` : ''}
4012
+ }
3979
4013
  `;
3980
- const accountPage = `<script lang="ts">
3981
- import { enhance } from '$app/forms'
3982
- let { data, form } = $props()
3983
- </script>
3984
-
3985
- <h1 class="st__title">Account</h1>
3986
- <div class="acct">
3987
- <p class="st__sub">Signed in as <strong>{data.user.name}</strong> ({data.user.email}).</p>
3988
- <form method="POST" use:enhance class="acct__form">
3989
- <h2 class="acct__h">Change password</h2>
3990
- {#if form?.ok}<p class="acct__ok">Password updated.</p>{/if}
3991
- {#if form?.error}<p class="acct__err" role="alert">{form.error}</p>{/if}
3992
- <label class="acct__field"><span>Current password</span><input name="current" type="password" autocomplete="current-password" required /></label>
3993
- <label class="acct__field"><span>New password</span><input name="next" type="password" autocomplete="new-password" minlength="8" required /></label>
3994
- <button class="acct__btn" type="submit">Update password</button>
3995
- </form>${twoFactor ? `
3996
- <form method="POST" action="?/tfa" use:enhance class="acct__form">
3997
- <h2 class="acct__h">Two-factor authentication</h2>
3998
- <p class="st-hint">{data.twoFactor ? 'Enabled - a code is emailed to you at sign-in.' : 'Add an emailed one-time code at sign-in.'}</p>
3999
- <input type="hidden" name="on" value={data.twoFactor ? 'false' : 'true'} />
4000
- <button class="acct__btn" type="submit">{data.twoFactor ? 'Disable' : 'Enable'} two-factor</button>
4001
- </form>` : ''}
4002
- </div>
4003
-
4004
- <style>
4005
- .acct { max-width: 420px; margin-top: 12px; }
4006
- .acct__form { display: flex; flex-direction: column; gap: 10px; margin-top: 16px; padding: 18px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; background: var(--sg-bg, #fff); }
4007
- .acct__h { margin: 0; font-size: 15px; }
4008
- .acct__field { display: flex; flex-direction: column; gap: 4px; font-size: 12.5px; font-weight: 600; color: var(--sg-muted, #64748b); }
4009
- .acct__field input { padding: 8px 10px; font: inherit; font-size: 14px; font-weight: 400; border: 1px solid var(--sg-input-border, #e6e8ec); border-radius: 8px; background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); }
4010
- .acct__btn { align-self: start; margin-top: 4px; padding: 8px 16px; font: inherit; font-size: 13.5px; font-weight: 620; color: var(--sg-on-accent, #fff); background: var(--sg-accent, #6366f1); border: none; border-radius: 8px; cursor: pointer; }
4011
- .acct__ok { margin: 0; color: #166534; font-size: 13px; }
4012
- .acct__err { margin: 0; color: var(--sg-danger, #b3261e); font-size: 13px; }
4013
- </style>
4014
+ const accountPage = `<script lang="ts">
4015
+ import { enhance } from '$app/forms'
4016
+ let { data, form } = $props()
4017
+ </script>
4018
+
4019
+ <h1 class="st__title">Account</h1>
4020
+ <div class="acct">
4021
+ <p class="st__sub">Signed in as <strong>{data.user.name}</strong> ({data.user.email}).</p>
4022
+ <form method="POST" use:enhance class="acct__form">
4023
+ <h2 class="acct__h">Change password</h2>
4024
+ {#if form?.ok}<p class="acct__ok">Password updated.</p>{/if}
4025
+ {#if form?.error}<p class="acct__err" role="alert">{form.error}</p>{/if}
4026
+ <label class="acct__field"><span>Current password</span><input name="current" type="password" autocomplete="current-password" required /></label>
4027
+ <label class="acct__field"><span>New password</span><input name="next" type="password" autocomplete="new-password" minlength="8" required /></label>
4028
+ <button class="acct__btn" type="submit">Update password</button>
4029
+ </form>${twoFactor ? `
4030
+ <form method="POST" action="?/tfa" use:enhance class="acct__form">
4031
+ <h2 class="acct__h">Two-factor authentication</h2>
4032
+ <p class="st-hint">{data.twoFactor ? 'Enabled - a code is emailed to you at sign-in.' : 'Add an emailed one-time code at sign-in.'}</p>
4033
+ <input type="hidden" name="on" value={data.twoFactor ? 'false' : 'true'} />
4034
+ <button class="acct__btn" type="submit">{data.twoFactor ? 'Disable' : 'Enable'} two-factor</button>
4035
+ </form>` : ''}
4036
+ </div>
4037
+
4038
+ <style>
4039
+ .acct { max-width: 420px; margin-top: 12px; }
4040
+ .acct__form { display: flex; flex-direction: column; gap: 10px; margin-top: 16px; padding: 18px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; background: var(--sg-bg, #fff); }
4041
+ .acct__h { margin: 0; font-size: 15px; }
4042
+ .acct__field { display: flex; flex-direction: column; gap: 4px; font-size: 12.5px; font-weight: 600; color: var(--sg-muted, #64748b); }
4043
+ .acct__field input { padding: 8px 10px; font: inherit; font-size: 14px; font-weight: 400; border: 1px solid var(--sg-input-border, #e6e8ec); border-radius: 8px; background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); }
4044
+ .acct__btn { align-self: start; margin-top: 4px; padding: 8px 16px; font: inherit; font-size: 13.5px; font-weight: 620; color: var(--sg-on-accent, #fff); background: var(--sg-accent, #6366f1); border: none; border-radius: 8px; cursor: pointer; }
4045
+ .acct__ok { margin: 0; color: #166534; font-size: 13px; }
4046
+ .acct__err { margin: 0; color: var(--sg-danger, #b3261e); font-size: 13px; }
4047
+ </style>
4014
4048
  `;
4015
4049
  // --- self-service register + password recovery (register flag) ---
4016
- const registerServerTs = `import { fail, redirect } from '@sveltejs/kit'
4017
- import type { Actions, PageServerLoad } from './$types'
4018
- import { createUser } from '../lib/server/users'
4019
- import { SESSION_COOKIE, SESSION_MAX_AGE, signSession } from '../lib/server/auth'
4020
-
4021
- export const load: PageServerLoad = async ({ locals }) => {
4022
- if (locals.user) throw redirect(302, '/')
4023
- return {}
4024
- }
4025
-
4026
- export const actions: Actions = {
4027
- default: async ({ request, cookies, url }) => {
4028
- const form = await request.formData()
4029
- const email = String(form.get('email') ?? '')
4030
- const name = String(form.get('name') ?? '')
4031
- const password = String(form.get('password') ?? '')
4032
- if (!email.trim() || !name.trim() || password.length < 8) return fail(400, { email, name, error: 'Enter a name, email, and a password of at least 8 characters.' })
4033
- const user = await createUser({ email, name, password, role: ${JSON.stringify(registerRole)} })
4034
- if (!user) return fail(409, { email, name, error: 'That email is already registered.' })
4035
- const token = await signSession({ email: user.email, name: user.name, role: user.role })
4036
- cookies.set(SESSION_COOKIE, token, { path: '/', httpOnly: true, sameSite: 'lax', secure: url.hostname !== 'localhost' && url.hostname !== '127.0.0.1', maxAge: SESSION_MAX_AGE })
4037
- throw redirect(302, '/')
4038
- },
4039
- }
4050
+ const registerServerTs = `import { fail, redirect } from '@sveltejs/kit'
4051
+ import type { Actions, PageServerLoad } from './$types'
4052
+ import { createUser } from '../lib/server/users'
4053
+ import { SESSION_COOKIE, SESSION_MAX_AGE, signSession } from '../lib/server/auth'
4054
+
4055
+ export const load: PageServerLoad = async ({ locals }) => {
4056
+ if (locals.user) throw redirect(302, '/')
4057
+ return {}
4058
+ }
4059
+
4060
+ export const actions: Actions = {
4061
+ default: async ({ request, cookies, url }) => {
4062
+ const form = await request.formData()
4063
+ const email = String(form.get('email') ?? '')
4064
+ const name = String(form.get('name') ?? '')
4065
+ const password = String(form.get('password') ?? '')
4066
+ if (!email.trim() || !name.trim() || password.length < 8) return fail(400, { email, name, error: 'Enter a name, email, and a password of at least 8 characters.' })
4067
+ const user = await createUser({ email, name, password, role: ${JSON.stringify(registerRole)} })
4068
+ if (!user) return fail(409, { email, name, error: 'That email is already registered.' })
4069
+ const token = await signSession({ email: user.email, name: user.name, role: user.role })
4070
+ cookies.set(SESSION_COOKIE, token, { path: '/', httpOnly: true, sameSite: 'lax', secure: url.hostname !== 'localhost' && url.hostname !== '127.0.0.1', maxAge: SESSION_MAX_AGE })
4071
+ throw redirect(302, '/')
4072
+ },
4073
+ }
4040
4074
  `;
4041
- const registerPage = `<script lang="ts">
4042
- import { enhance } from '$app/forms'
4043
- let { form } = $props()
4044
- </script>
4045
-
4046
- <div class="auth">
4047
- <form method="POST" use:enhance class="auth__card">
4048
- <h1 class="auth__title">Create account</h1>
4049
- <p class="auth__sub">${jsStrHtml(project.title || '')}</p>
4050
- {#if form?.error}<p class="auth__err" role="alert">{form.error}</p>{/if}
4051
- <label class="auth__field"><span>Name</span><input name="name" autocomplete="name" value={form?.name ?? ''} required /></label>
4052
- <label class="auth__field"><span>Email</span><input name="email" type="email" autocomplete="username" value={form?.email ?? ''} required /></label>
4053
- <label class="auth__field"><span>Password</span><input name="password" type="password" autocomplete="new-password" minlength="8" required /></label>
4054
- <button class="auth__btn" type="submit">Create account</button>
4055
- <p class="auth__links"><a href="/login">Have an account? Sign in</a></p>
4056
- </form>
4057
- </div>
4058
-
4059
- ${authStyle}
4075
+ const registerPage = `<script lang="ts">
4076
+ import { enhance } from '$app/forms'
4077
+ let { form } = $props()
4078
+ </script>
4079
+
4080
+ <div class="auth">
4081
+ <form method="POST" use:enhance class="auth__card">
4082
+ <h1 class="auth__title">Create account</h1>
4083
+ <p class="auth__sub">${jsStrHtml(project.title || '')}</p>
4084
+ {#if form?.error}<p class="auth__err" role="alert">{form.error}</p>{/if}
4085
+ <label class="auth__field"><span>Name</span><input name="name" autocomplete="name" value={form?.name ?? ''} required /></label>
4086
+ <label class="auth__field"><span>Email</span><input name="email" type="email" autocomplete="username" value={form?.email ?? ''} required /></label>
4087
+ <label class="auth__field"><span>Password</span><input name="password" type="password" autocomplete="new-password" minlength="8" required /></label>
4088
+ <button class="auth__btn" type="submit">Create account</button>
4089
+ <p class="auth__links"><a href="/login">Have an account? Sign in</a></p>
4090
+ </form>
4091
+ </div>
4092
+
4093
+ ${authStyle}
4060
4094
  `;
4061
4095
  const forgotSend = emailReal
4062
- ? `if (user) {
4063
- const link = url.origin + '/reset-password?token=' + encodeURIComponent(await signReset(user.email))
4064
- await sendEmail(user.email, 'Reset your password', '<p>Reset your password with the link below (expires in 30 minutes):</p><p><a href="' + link + '">' + link + '</a></p>')
4096
+ ? `if (user) {
4097
+ const link = url.origin + '/reset-password?token=' + encodeURIComponent(await signReset(user.email))
4098
+ await sendEmail(user.email, 'Reset your password', '<p>Reset your password with the link below (expires in 30 minutes):</p><p><a href="' + link + '">' + link + '</a></p>')
4065
4099
  }`
4066
4100
  : `if (user) await sendResetEmail(user.email, url.origin + '/reset-password?token=' + encodeURIComponent(await signReset(user.email)))`;
4067
- const forgotServerTs = `import type { Actions } from './$types'
4068
- import { findUser } from '../lib/server/users'
4069
- import { signReset${emailReal ? '' : ', sendResetEmail'} } from '$lib/server/auth'${emailReal ? "\nimport { sendEmail } from '$lib/server/email'" : ''}
4070
-
4071
- export const actions: Actions = {
4072
- default: async ({ request, url }) => {
4073
- const email = String((await request.formData()).get('email') ?? '').trim().toLowerCase()
4074
- const user = await findUser(email)
4075
- // Respond identically whether or not the account exists (no account enumeration).
4076
- ${forgotSend}
4077
- return { sent: true }
4078
- },
4079
- }
4101
+ const forgotServerTs = `import type { Actions } from './$types'
4102
+ import { findUser } from '../lib/server/users'
4103
+ import { signReset${emailReal ? '' : ', sendResetEmail'} } from '$lib/server/auth'${emailReal ? "\nimport { sendEmail } from '$lib/server/email'" : ''}
4104
+
4105
+ export const actions: Actions = {
4106
+ default: async ({ request, url }) => {
4107
+ const email = String((await request.formData()).get('email') ?? '').trim().toLowerCase()
4108
+ const user = await findUser(email)
4109
+ // Respond identically whether or not the account exists (no account enumeration).
4110
+ ${forgotSend}
4111
+ return { sent: true }
4112
+ },
4113
+ }
4080
4114
  `;
4081
- const forgotPage = `<script lang="ts">
4082
- import { enhance } from '$app/forms'
4083
- let { form } = $props()
4084
- </script>
4085
-
4086
- <div class="auth">
4087
- <form method="POST" use:enhance class="auth__card">
4088
- <h1 class="auth__title">Reset password</h1>
4089
- <p class="auth__sub">We'll email you a reset link.</p>
4090
- {#if form?.sent}<p class="auth__ok">If that account exists, a reset link is on its way. (Dev: the link is logged to the server console.)</p>{/if}
4091
- <label class="auth__field"><span>Email</span><input name="email" type="email" autocomplete="username" required /></label>
4092
- <button class="auth__btn" type="submit">Send reset link</button>
4093
- <p class="auth__links"><a href="/login">Back to sign in</a></p>
4094
- </form>
4095
- </div>
4096
-
4097
- ${authStyle}
4115
+ const forgotPage = `<script lang="ts">
4116
+ import { enhance } from '$app/forms'
4117
+ let { form } = $props()
4118
+ </script>
4119
+
4120
+ <div class="auth">
4121
+ <form method="POST" use:enhance class="auth__card">
4122
+ <h1 class="auth__title">Reset password</h1>
4123
+ <p class="auth__sub">We'll email you a reset link.</p>
4124
+ {#if form?.sent}<p class="auth__ok">If that account exists, a reset link is on its way. (Dev: the link is logged to the server console.)</p>{/if}
4125
+ <label class="auth__field"><span>Email</span><input name="email" type="email" autocomplete="username" required /></label>
4126
+ <button class="auth__btn" type="submit">Send reset link</button>
4127
+ <p class="auth__links"><a href="/login">Back to sign in</a></p>
4128
+ </form>
4129
+ </div>
4130
+
4131
+ ${authStyle}
4098
4132
  `;
4099
- const resetServerTs = `import { fail, redirect } from '@sveltejs/kit'
4100
- import type { Actions, PageServerLoad } from './$types'
4101
- import { readReset } from '../lib/server/auth'
4102
- import { updatePassword } from '../lib/server/users'
4103
-
4104
- export const load: PageServerLoad = async ({ url }) => {
4105
- return { valid: !!(await readReset(url.searchParams.get('token') ?? undefined)) }
4106
- }
4107
-
4108
- export const actions: Actions = {
4109
- default: async ({ request, url }) => {
4110
- const email = await readReset(url.searchParams.get('token') ?? undefined)
4111
- if (!email) return fail(400, { error: 'This reset link is invalid or has expired.' })
4112
- const next = String((await request.formData()).get('password') ?? '')
4113
- if (next.length < 8) return fail(400, { error: 'Password must be at least 8 characters.' })
4114
- await updatePassword(email, next)
4115
- throw redirect(302, '/login')
4116
- },
4117
- }
4133
+ const resetServerTs = `import { fail, redirect } from '@sveltejs/kit'
4134
+ import type { Actions, PageServerLoad } from './$types'
4135
+ import { readReset } from '../lib/server/auth'
4136
+ import { updatePassword } from '../lib/server/users'
4137
+
4138
+ export const load: PageServerLoad = async ({ url }) => {
4139
+ return { valid: !!(await readReset(url.searchParams.get('token') ?? undefined)) }
4140
+ }
4141
+
4142
+ export const actions: Actions = {
4143
+ default: async ({ request, url }) => {
4144
+ const email = await readReset(url.searchParams.get('token') ?? undefined)
4145
+ if (!email) return fail(400, { error: 'This reset link is invalid or has expired.' })
4146
+ const next = String((await request.formData()).get('password') ?? '')
4147
+ if (next.length < 8) return fail(400, { error: 'Password must be at least 8 characters.' })
4148
+ await updatePassword(email, next)
4149
+ throw redirect(302, '/login')
4150
+ },
4151
+ }
4118
4152
  `;
4119
- const resetPage = `<script lang="ts">
4120
- import { enhance } from '$app/forms'
4121
- let { data, form } = $props()
4122
- </script>
4123
-
4124
- <div class="auth">
4125
- <form method="POST" use:enhance class="auth__card">
4126
- <h1 class="auth__title">Set a new password</h1>
4127
- {#if !data.valid}
4128
- <p class="auth__err">This reset link is invalid or has expired. <a href="/forgot-password">Request a new one</a>.</p>
4129
- {:else}
4130
- {#if form?.error}<p class="auth__err" role="alert">{form.error}</p>{/if}
4131
- <label class="auth__field"><span>New password</span><input name="password" type="password" autocomplete="new-password" minlength="8" required /></label>
4132
- <button class="auth__btn" type="submit">Update password</button>
4133
- {/if}
4134
- <p class="auth__links"><a href="/login">Back to sign in</a></p>
4135
- </form>
4136
- </div>
4137
-
4138
- ${authStyle}
4153
+ const resetPage = `<script lang="ts">
4154
+ import { enhance } from '$app/forms'
4155
+ let { data, form } = $props()
4156
+ </script>
4157
+
4158
+ <div class="auth">
4159
+ <form method="POST" use:enhance class="auth__card">
4160
+ <h1 class="auth__title">Set a new password</h1>
4161
+ {#if !data.valid}
4162
+ <p class="auth__err">This reset link is invalid or has expired. <a href="/forgot-password">Request a new one</a>.</p>
4163
+ {:else}
4164
+ {#if form?.error}<p class="auth__err" role="alert">{form.error}</p>{/if}
4165
+ <label class="auth__field"><span>New password</span><input name="password" type="password" autocomplete="new-password" minlength="8" required /></label>
4166
+ <button class="auth__btn" type="submit">Update password</button>
4167
+ {/if}
4168
+ <p class="auth__links"><a href="/login">Back to sign in</a></p>
4169
+ </form>
4170
+ </div>
4171
+
4172
+ ${authStyle}
4139
4173
  `;
4140
4174
  // --- admin user management (userAdmin flag: DB-backed + RBAC) ---
4141
- const usersServerTs = `import { fail, redirect } from '@sveltejs/kit'
4142
- import type { Actions, PageServerLoad, RequestEvent } from './$types'
4143
- import { getServerRole, canScreen, ROLES } from '../lib/access'
4144
- import { listUsers, createUser, setUserRole, deleteUser } from '../lib/server/users'
4145
-
4146
- // Only a full-access role (screens: '*') may manage users.
4147
- function guard(event: RequestEvent): void {
4148
- if (!canScreen(getServerRole(event), '__users__')) throw redirect(302, '/')
4149
- }
4150
-
4151
- export const load: PageServerLoad = async (event) => {
4152
- guard(event)
4153
- return { users: await listUsers(), roles: ROLES }
4154
- }
4155
-
4156
- export const actions: Actions = {
4157
- create: async (event) => {
4158
- guard(event)
4159
- const form = await event.request.formData()
4160
- const email = String(form.get('email') ?? '')
4161
- const name = String(form.get('name') ?? '')
4162
- const password = String(form.get('password') ?? '')
4163
- const role = String(form.get('role') ?? ROLES[0])
4164
- if (!email.trim() || !name.trim() || password.length < 8) return fail(400, { error: 'Name, email, and an 8+ character password are required.' })
4165
- if (!(await createUser({ email, name, password, role }))) return fail(409, { error: 'That email already exists.' })
4166
- return { ok: 'created' }
4167
- },
4168
- role: async (event) => {
4169
- guard(event)
4170
- const form = await event.request.formData()
4171
- await setUserRole(String(form.get('email') ?? ''), String(form.get('role') ?? ''))
4172
- return { ok: 'updated' }
4173
- },
4174
- remove: async (event) => {
4175
- guard(event)
4176
- await deleteUser(String((await event.request.formData()).get('email') ?? ''))
4177
- return { ok: 'removed' }
4178
- },
4179
- }
4175
+ const usersServerTs = `import { fail, redirect } from '@sveltejs/kit'
4176
+ import type { Actions, PageServerLoad, RequestEvent } from './$types'
4177
+ import { getServerRole, canScreen, ROLES } from '../lib/access'
4178
+ import { listUsers, createUser, setUserRole, deleteUser } from '../lib/server/users'
4179
+
4180
+ // Only a full-access role (screens: '*') may manage users.
4181
+ function guard(event: RequestEvent): void {
4182
+ if (!canScreen(getServerRole(event), '__users__')) throw redirect(302, '/')
4183
+ }
4184
+
4185
+ export const load: PageServerLoad = async (event) => {
4186
+ guard(event)
4187
+ return { users: await listUsers(), roles: ROLES }
4188
+ }
4189
+
4190
+ export const actions: Actions = {
4191
+ create: async (event) => {
4192
+ guard(event)
4193
+ const form = await event.request.formData()
4194
+ const email = String(form.get('email') ?? '')
4195
+ const name = String(form.get('name') ?? '')
4196
+ const password = String(form.get('password') ?? '')
4197
+ const role = String(form.get('role') ?? ROLES[0])
4198
+ if (!email.trim() || !name.trim() || password.length < 8) return fail(400, { error: 'Name, email, and an 8+ character password are required.' })
4199
+ if (!(await createUser({ email, name, password, role }))) return fail(409, { error: 'That email already exists.' })
4200
+ return { ok: 'created' }
4201
+ },
4202
+ role: async (event) => {
4203
+ guard(event)
4204
+ const form = await event.request.formData()
4205
+ await setUserRole(String(form.get('email') ?? ''), String(form.get('role') ?? ''))
4206
+ return { ok: 'updated' }
4207
+ },
4208
+ remove: async (event) => {
4209
+ guard(event)
4210
+ await deleteUser(String((await event.request.formData()).get('email') ?? ''))
4211
+ return { ok: 'removed' }
4212
+ },
4213
+ }
4180
4214
  `;
4181
- const usersPage = `<script lang="ts">
4182
- import { enhance } from '$app/forms'
4183
- let { data, form } = $props()
4184
- </script>
4185
-
4186
- <h1 class="st__title">Users</h1>
4187
- {#if form?.error}<p class="usr__msg usr__msg--err" role="alert">{form.error}</p>{/if}
4188
- <div class="usr">
4189
- <table class="usr__table">
4190
- <thead><tr><th>Name</th><th>Email</th><th>Role</th><th></th></tr></thead>
4191
- <tbody>
4192
- {#each data.users as u (u.email)}
4193
- <tr>
4194
- <td>{u.name}</td>
4195
- <td>{u.email}</td>
4196
- <td>
4197
- <form method="POST" action="?/role" use:enhance>
4198
- <input type="hidden" name="email" value={u.email} />
4199
- <select name="role" onchange={(e) => e.currentTarget.form?.requestSubmit()}>
4200
- {#each data.roles as r (r)}<option value={r} selected={r === u.role}>{r}</option>{/each}
4201
- </select>
4202
- </form>
4203
- </td>
4204
- <td>
4205
- <form method="POST" action="?/remove" use:enhance>
4206
- <input type="hidden" name="email" value={u.email} />
4207
- <button class="usr__del" type="submit">Remove</button>
4208
- </form>
4209
- </td>
4210
- </tr>
4211
- {/each}
4212
- </tbody>
4213
- </table>
4214
-
4215
- <form method="POST" action="?/create" use:enhance class="usr__add">
4216
- <h2 class="usr__h">Add user</h2>
4217
- <input name="name" placeholder="Name" required />
4218
- <input name="email" type="email" placeholder="Email" required />
4219
- <input name="password" type="password" placeholder="Password (8+)" minlength="8" required />
4220
- <select name="role">{#each data.roles as r (r)}<option value={r}>{r}</option>{/each}</select>
4221
- <button class="usr__btn" type="submit">Add user</button>
4222
- </form>
4223
- </div>
4224
-
4225
- <style>
4226
- .usr { display: flex; flex-direction: column; gap: 18px; margin-top: 14px; }
4227
- .usr__table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
4228
- .usr__table th { text-align: left; padding: 8px 10px; color: var(--sg-muted, #64748b); font-weight: 600; border-bottom: 1px solid var(--sg-border, #e6e8ec); }
4229
- .usr__table td { padding: 7px 10px; border-bottom: 1px solid var(--sg-border, #f1f5f9); }
4230
- .usr__table select { padding: 5px 8px; font: inherit; font-size: 13px; border: 1px solid var(--sg-input-border, #e6e8ec); border-radius: 7px; background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); }
4231
- .usr__del { padding: 4px 10px; font: inherit; font-size: 12.5px; color: #dc2626; background: none; border: 1px solid color-mix(in srgb, #dc2626 40%, var(--sg-border, #e6e8ec)); border-radius: 7px; cursor: pointer; }
4232
- .usr__add { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding: 16px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; background: var(--sg-bg, #fff); }
4233
- .usr__h { width: 100%; margin: 0 0 4px; font-size: 15px; }
4234
- .usr__add input, .usr__add select { padding: 8px 10px; font: inherit; font-size: 13.5px; border: 1px solid var(--sg-input-border, #e6e8ec); border-radius: 8px; background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); }
4235
- .usr__btn { padding: 8px 16px; font: inherit; font-size: 13.5px; font-weight: 620; color: var(--sg-on-accent, #fff); background: var(--sg-accent, #6366f1); border: none; border-radius: 8px; cursor: pointer; }
4236
- .usr__msg { margin: 0 0 10px; padding: 8px 11px; border-radius: 9px; font-size: 13px; }
4237
- .usr__msg--err { color: var(--sg-danger, #b3261e); background: color-mix(in srgb, var(--sg-danger, #dc2626) 9%, var(--sg-bg, #fff)); border: 1px solid color-mix(in srgb, var(--sg-danger, #dc2626) 35%, var(--sg-border, #e6e8ec)); }
4238
- </style>
4215
+ const usersPage = `<script lang="ts">
4216
+ import { enhance } from '$app/forms'
4217
+ let { data, form } = $props()
4218
+ </script>
4219
+
4220
+ <h1 class="st__title">Users</h1>
4221
+ {#if form?.error}<p class="usr__msg usr__msg--err" role="alert">{form.error}</p>{/if}
4222
+ <div class="usr">
4223
+ <table class="usr__table">
4224
+ <thead><tr><th>Name</th><th>Email</th><th>Role</th><th></th></tr></thead>
4225
+ <tbody>
4226
+ {#each data.users as u (u.email)}
4227
+ <tr>
4228
+ <td>{u.name}</td>
4229
+ <td>{u.email}</td>
4230
+ <td>
4231
+ <form method="POST" action="?/role" use:enhance>
4232
+ <input type="hidden" name="email" value={u.email} />
4233
+ <select name="role" onchange={(e) => e.currentTarget.form?.requestSubmit()}>
4234
+ {#each data.roles as r (r)}<option value={r} selected={r === u.role}>{r}</option>{/each}
4235
+ </select>
4236
+ </form>
4237
+ </td>
4238
+ <td>
4239
+ <form method="POST" action="?/remove" use:enhance>
4240
+ <input type="hidden" name="email" value={u.email} />
4241
+ <button class="usr__del" type="submit">Remove</button>
4242
+ </form>
4243
+ </td>
4244
+ </tr>
4245
+ {/each}
4246
+ </tbody>
4247
+ </table>
4248
+
4249
+ <form method="POST" action="?/create" use:enhance class="usr__add">
4250
+ <h2 class="usr__h">Add user</h2>
4251
+ <input name="name" placeholder="Name" required />
4252
+ <input name="email" type="email" placeholder="Email" required />
4253
+ <input name="password" type="password" placeholder="Password (8+)" minlength="8" required />
4254
+ <select name="role">{#each data.roles as r (r)}<option value={r}>{r}</option>{/each}</select>
4255
+ <button class="usr__btn" type="submit">Add user</button>
4256
+ </form>
4257
+ </div>
4258
+
4259
+ <style>
4260
+ .usr { display: flex; flex-direction: column; gap: 18px; margin-top: 14px; }
4261
+ .usr__table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
4262
+ .usr__table th { text-align: left; padding: 8px 10px; color: var(--sg-muted, #64748b); font-weight: 600; border-bottom: 1px solid var(--sg-border, #e6e8ec); }
4263
+ .usr__table td { padding: 7px 10px; border-bottom: 1px solid var(--sg-border, #f1f5f9); }
4264
+ .usr__table select { padding: 5px 8px; font: inherit; font-size: 13px; border: 1px solid var(--sg-input-border, #e6e8ec); border-radius: 7px; background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); }
4265
+ .usr__del { padding: 4px 10px; font: inherit; font-size: 12.5px; color: #dc2626; background: none; border: 1px solid color-mix(in srgb, #dc2626 40%, var(--sg-border, #e6e8ec)); border-radius: 7px; cursor: pointer; }
4266
+ .usr__add { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding: 16px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; background: var(--sg-bg, #fff); }
4267
+ .usr__h { width: 100%; margin: 0 0 4px; font-size: 15px; }
4268
+ .usr__add input, .usr__add select { padding: 8px 10px; font: inherit; font-size: 13.5px; border: 1px solid var(--sg-input-border, #e6e8ec); border-radius: 8px; background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); }
4269
+ .usr__btn { padding: 8px 16px; font: inherit; font-size: 13.5px; font-weight: 620; color: var(--sg-on-accent, #fff); background: var(--sg-accent, #6366f1); border: none; border-radius: 8px; cursor: pointer; }
4270
+ .usr__msg { margin: 0 0 10px; padding: 8px 11px; border-radius: 9px; font-size: 13px; }
4271
+ .usr__msg--err { color: var(--sg-danger, #b3261e); background: color-mix(in srgb, var(--sg-danger, #dc2626) 9%, var(--sg-bg, #fff)); border: 1px solid color-mix(in srgb, var(--sg-danger, #dc2626) 35%, var(--sg-border, #e6e8ec)); }
4272
+ </style>
4239
4273
  `;
4240
4274
  // --- real email (Resend HTTP / SMTP nodemailer / dev console) ---
4241
- const emailTs = `// Regenerated by SvGrid Studio. Email delivery: Resend (HTTP API, works on edge) or
4242
- // SMTP (nodemailer, Node runtimes). Logs to the console in dev when neither is set.
4243
- import { env } from '$env/dynamic/private'
4244
-
4245
- export async function sendEmail(to: string, subject: string, html: string): Promise<void> {
4246
- const from = env.EMAIL_FROM || 'onboarding@resend.dev'
4247
- if (env.RESEND_API_KEY) {
4248
- const res = await fetch('https://api.resend.com/emails', {
4249
- method: 'POST',
4250
- headers: { authorization: 'Bearer ' + env.RESEND_API_KEY, 'content-type': 'application/json' },
4251
- body: JSON.stringify({ from, to, subject, html }),
4252
- })
4253
- if (!res.ok) throw new Error('Resend send failed: ' + res.status + ' ' + (await res.text()))
4254
- return
4255
- }
4256
- if (env.SMTP_HOST) {
4257
- // Dynamic import so edge builds (Resend, or no email) never bundle nodemailer.
4258
- const nodemailer = (await import('nodemailer')).default
4259
- const transport = nodemailer.createTransport({
4260
- host: env.SMTP_HOST,
4261
- port: Number(env.SMTP_PORT ?? 587),
4262
- secure: env.SMTP_SECURE === 'true',
4263
- auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASS } : undefined,
4264
- })
4265
- await transport.sendMail({ from, to, subject, html })
4266
- return
4267
- }
4268
- console.log('[email] (dev - set RESEND_API_KEY or SMTP_* to send) To: ' + to + ' | ' + subject + '\\n' + html)
4269
- }
4275
+ const emailTs = `// Regenerated by SvGrid Studio. Email delivery: Resend (HTTP API, works on edge) or
4276
+ // SMTP (nodemailer, Node runtimes). Logs to the console in dev when neither is set.
4277
+ import { env } from '$env/dynamic/private'
4278
+
4279
+ export async function sendEmail(to: string, subject: string, html: string): Promise<void> {
4280
+ const from = env.EMAIL_FROM || 'onboarding@resend.dev'
4281
+ if (env.RESEND_API_KEY) {
4282
+ const res = await fetch('https://api.resend.com/emails', {
4283
+ method: 'POST',
4284
+ headers: { authorization: 'Bearer ' + env.RESEND_API_KEY, 'content-type': 'application/json' },
4285
+ body: JSON.stringify({ from, to, subject, html }),
4286
+ })
4287
+ if (!res.ok) throw new Error('Resend send failed: ' + res.status + ' ' + (await res.text()))
4288
+ return
4289
+ }
4290
+ if (env.SMTP_HOST) {
4291
+ // Dynamic import so edge builds (Resend, or no email) never bundle nodemailer.
4292
+ const nodemailer = (await import('nodemailer')).default
4293
+ const transport = nodemailer.createTransport({
4294
+ host: env.SMTP_HOST,
4295
+ port: Number(env.SMTP_PORT ?? 587),
4296
+ secure: env.SMTP_SECURE === 'true',
4297
+ auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASS } : undefined,
4298
+ })
4299
+ await transport.sendMail({ from, to, subject, html })
4300
+ return
4301
+ }
4302
+ console.log('[email] (dev - set RESEND_API_KEY or SMTP_* to send) To: ' + to + ' | ' + subject + '\\n' + html)
4303
+ }
4270
4304
  `;
4271
4305
  // --- email 2FA verify step ---
4272
- const verifyServerTs = `import { fail, redirect } from '@sveltejs/kit'
4273
- import type { Actions, PageServerLoad } from './$types'
4274
- import { SESSION_COOKIE, SESSION_MAX_AGE, TFA_COOKIE, readChallenge, signSession } from '../lib/server/auth'
4275
- import { findUser } from '../lib/server/users'
4276
-
4277
- export const load: PageServerLoad = async ({ cookies }) => {
4278
- if (!cookies.get(TFA_COOKIE)) throw redirect(302, '/login')
4279
- return {}
4280
- }
4281
-
4282
- export const actions: Actions = {
4283
- default: async ({ request, cookies, url }) => {
4284
- const code = String((await request.formData()).get('code') ?? '')
4285
- const email = await readChallenge(cookies.get(TFA_COOKIE), code)
4286
- if (!email) return fail(401, { error: 'That code is invalid or has expired.' })
4287
- const user = await findUser(email)
4288
- if (!user) return fail(401, { error: 'Account not found.' })
4289
- cookies.delete(TFA_COOKIE, { path: '/' })
4290
- cookies.set(SESSION_COOKIE, await signSession({ email: user.email, name: user.name, role: user.role }), {
4291
- path: '/', httpOnly: true, sameSite: 'lax', secure: url.hostname !== 'localhost' && url.hostname !== '127.0.0.1', maxAge: SESSION_MAX_AGE,
4292
- })
4293
- throw redirect(302, url.searchParams.get('redirectTo') || '/')
4294
- },
4295
- }
4306
+ const verifyServerTs = `import { fail, redirect } from '@sveltejs/kit'
4307
+ import type { Actions, PageServerLoad } from './$types'
4308
+ import { SESSION_COOKIE, SESSION_MAX_AGE, TFA_COOKIE, readChallenge, signSession } from '../lib/server/auth'
4309
+ import { findUser } from '../lib/server/users'
4310
+
4311
+ export const load: PageServerLoad = async ({ cookies }) => {
4312
+ if (!cookies.get(TFA_COOKIE)) throw redirect(302, '/login')
4313
+ return {}
4314
+ }
4315
+
4316
+ export const actions: Actions = {
4317
+ default: async ({ request, cookies, url }) => {
4318
+ const code = String((await request.formData()).get('code') ?? '')
4319
+ const email = await readChallenge(cookies.get(TFA_COOKIE), code)
4320
+ if (!email) return fail(401, { error: 'That code is invalid or has expired.' })
4321
+ const user = await findUser(email)
4322
+ if (!user) return fail(401, { error: 'Account not found.' })
4323
+ cookies.delete(TFA_COOKIE, { path: '/' })
4324
+ cookies.set(SESSION_COOKIE, await signSession({ email: user.email, name: user.name, role: user.role }), {
4325
+ path: '/', httpOnly: true, sameSite: 'lax', secure: url.hostname !== 'localhost' && url.hostname !== '127.0.0.1', maxAge: SESSION_MAX_AGE,
4326
+ })
4327
+ throw redirect(302, url.searchParams.get('redirectTo') || '/')
4328
+ },
4329
+ }
4296
4330
  `;
4297
- const verifyPage = `<script lang="ts">
4298
- import { enhance } from '$app/forms'
4299
- let { form } = $props()
4300
- </script>
4301
-
4302
- <div class="auth">
4303
- <form method="POST" use:enhance class="auth__card">
4304
- <h1 class="auth__title">Enter your code</h1>
4305
- <p class="auth__sub">We emailed you a 6-digit verification code.</p>
4306
- {#if form?.error}<p class="auth__err" role="alert">{form.error}</p>{/if}
4307
- <label class="auth__field"><span>Code</span><input name="code" inputmode="numeric" autocomplete="one-time-code" required /></label>
4308
- <button class="auth__btn" type="submit">Verify</button>
4309
- <p class="auth__links"><a href="/login">Back to sign in</a></p>
4310
- </form>
4311
- </div>
4312
-
4313
- ${authStyle}
4331
+ const verifyPage = `<script lang="ts">
4332
+ import { enhance } from '$app/forms'
4333
+ let { form } = $props()
4334
+ </script>
4335
+
4336
+ <div class="auth">
4337
+ <form method="POST" use:enhance class="auth__card">
4338
+ <h1 class="auth__title">Enter your code</h1>
4339
+ <p class="auth__sub">We emailed you a 6-digit verification code.</p>
4340
+ {#if form?.error}<p class="auth__err" role="alert">{form.error}</p>{/if}
4341
+ <label class="auth__field"><span>Code</span><input name="code" inputmode="numeric" autocomplete="one-time-code" required /></label>
4342
+ <button class="auth__btn" type="submit">Verify</button>
4343
+ <p class="auth__links"><a href="/login">Back to sign in</a></p>
4344
+ </form>
4345
+ </div>
4346
+
4347
+ ${authStyle}
4314
4348
  `;
4315
4349
  // --- OAuth / OIDC (dependency-free authorization-code + PKCE) ---
4316
4350
  const providerSet = `new Set<Provider>([${oauthProviders.map((p) => JSON.stringify(p)).join(', ')}])`;
4317
- const oauthTs = `// Regenerated by SvGrid Studio. Dependency-free OAuth 2.0 / OpenID Connect sign-in
4318
- // (authorization-code + PKCE). Set each provider's client id/secret in the environment;
4319
- // register the redirect URI <origin>/auth/<provider>/callback with the provider.
4320
- import { env } from '$env/dynamic/private'
4321
-
4322
- export type Provider = 'github' | 'google' | 'oidc'
4323
- type Endpoints = { authorize: string; token: string; userinfo: string }
4324
- export type ProviderConfig = { clientId: string; clientSecret: string; scope: string; pkce: boolean; endpoints: Endpoints }
4325
-
4326
- function b64url(bytes: ArrayBuffer): string {
4327
- const b = new Uint8Array(bytes)
4328
- let s = ''
4329
- for (const byte of b) s += String.fromCharCode(byte)
4330
- return btoa(s).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')
4331
- }
4332
- export function randomToken(): string { return b64url(crypto.getRandomValues(new Uint8Array(32)).buffer) }
4333
- export async function pkceChallenge(verifier: string): Promise<string> {
4334
- return b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)))
4335
- }
4336
-
4337
- async function endpointsFor(provider: Provider): Promise<Endpoints> {
4338
- if (provider === 'github') return { authorize: 'https://github.com/login/oauth/authorize', token: 'https://github.com/login/oauth/access_token', userinfo: 'https://api.github.com/user' }
4339
- if (provider === 'google') return { authorize: 'https://accounts.google.com/o/oauth2/v2/auth', token: 'https://oauth2.googleapis.com/token', userinfo: 'https://openidconnect.googleapis.com/v1/userinfo' }
4340
- // Generic OIDC (Azure AD / Entra, Okta, Auth0, Keycloak): discover from OIDC_ISSUER.
4341
- const issuer = (env.OIDC_ISSUER ?? '').replace(/\\/$/, '')
4342
- const disc = (await (await fetch(issuer + '/.well-known/openid-configuration')).json()) as { authorization_endpoint: string; token_endpoint: string; userinfo_endpoint: string }
4343
- return { authorize: disc.authorization_endpoint, token: disc.token_endpoint, userinfo: disc.userinfo_endpoint }
4344
- }
4345
-
4346
- export async function providerConfig(provider: Provider): Promise<ProviderConfig> {
4347
- const prefix = provider.toUpperCase()
4348
- return {
4349
- clientId: env[prefix + '_CLIENT_ID'] ?? '',
4350
- clientSecret: env[prefix + '_CLIENT_SECRET'] ?? '',
4351
- scope: provider === 'github' ? 'read:user user:email' : 'openid email profile',
4352
- pkce: provider !== 'github',
4353
- endpoints: await endpointsFor(provider),
4354
- }
4355
- }
4356
-
4357
- export type OAuthProfile = { email: string; name: string }
4358
- export async function fetchProfile(provider: Provider, accessToken: string, userinfo: string): Promise<OAuthProfile | null> {
4359
- const headers = { authorization: 'Bearer ' + accessToken, accept: 'application/json', 'user-agent': 'svgrid-app' }
4360
- const data = (await (await fetch(userinfo, { headers })).json()) as Record<string, unknown>
4361
- let email = typeof data.email === 'string' ? data.email : ''
4362
- const name = typeof data.name === 'string' ? data.name : (typeof data.login === 'string' ? data.login : email)
4363
- if (provider === 'github' && !email) {
4364
- const emails = (await (await fetch('https://api.github.com/user/emails', { headers })).json()) as Array<{ email: string; primary: boolean; verified: boolean }>
4365
- email = (emails.find((e) => e.primary && e.verified) ?? emails.find((e) => e.verified))?.email ?? ''
4366
- }
4367
- return email ? { email: email.toLowerCase(), name: name || email } : null
4368
- }
4351
+ const oauthTs = `// Regenerated by SvGrid Studio. Dependency-free OAuth 2.0 / OpenID Connect sign-in
4352
+ // (authorization-code + PKCE). Set each provider's client id/secret in the environment;
4353
+ // register the redirect URI <origin>/auth/<provider>/callback with the provider.
4354
+ import { env } from '$env/dynamic/private'
4355
+
4356
+ export type Provider = 'github' | 'google' | 'oidc'
4357
+ type Endpoints = { authorize: string; token: string; userinfo: string }
4358
+ export type ProviderConfig = { clientId: string; clientSecret: string; scope: string; pkce: boolean; endpoints: Endpoints }
4359
+
4360
+ function b64url(bytes: ArrayBuffer): string {
4361
+ const b = new Uint8Array(bytes)
4362
+ let s = ''
4363
+ for (const byte of b) s += String.fromCharCode(byte)
4364
+ return btoa(s).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')
4365
+ }
4366
+ export function randomToken(): string { return b64url(crypto.getRandomValues(new Uint8Array(32)).buffer) }
4367
+ export async function pkceChallenge(verifier: string): Promise<string> {
4368
+ return b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)))
4369
+ }
4370
+
4371
+ async function endpointsFor(provider: Provider): Promise<Endpoints> {
4372
+ if (provider === 'github') return { authorize: 'https://github.com/login/oauth/authorize', token: 'https://github.com/login/oauth/access_token', userinfo: 'https://api.github.com/user' }
4373
+ if (provider === 'google') return { authorize: 'https://accounts.google.com/o/oauth2/v2/auth', token: 'https://oauth2.googleapis.com/token', userinfo: 'https://openidconnect.googleapis.com/v1/userinfo' }
4374
+ // Generic OIDC (Azure AD / Entra, Okta, Auth0, Keycloak): discover from OIDC_ISSUER.
4375
+ const issuer = (env.OIDC_ISSUER ?? '').replace(/\\/$/, '')
4376
+ const disc = (await (await fetch(issuer + '/.well-known/openid-configuration')).json()) as { authorization_endpoint: string; token_endpoint: string; userinfo_endpoint: string }
4377
+ return { authorize: disc.authorization_endpoint, token: disc.token_endpoint, userinfo: disc.userinfo_endpoint }
4378
+ }
4379
+
4380
+ export async function providerConfig(provider: Provider): Promise<ProviderConfig> {
4381
+ const prefix = provider.toUpperCase()
4382
+ return {
4383
+ clientId: env[prefix + '_CLIENT_ID'] ?? '',
4384
+ clientSecret: env[prefix + '_CLIENT_SECRET'] ?? '',
4385
+ scope: provider === 'github' ? 'read:user user:email' : 'openid email profile',
4386
+ pkce: provider !== 'github',
4387
+ endpoints: await endpointsFor(provider),
4388
+ }
4389
+ }
4390
+
4391
+ export type OAuthProfile = { email: string; name: string }
4392
+ export async function fetchProfile(provider: Provider, accessToken: string, userinfo: string): Promise<OAuthProfile | null> {
4393
+ const headers = { authorization: 'Bearer ' + accessToken, accept: 'application/json', 'user-agent': 'svgrid-app' }
4394
+ const data = (await (await fetch(userinfo, { headers })).json()) as Record<string, unknown>
4395
+ let email = typeof data.email === 'string' ? data.email : ''
4396
+ const name = typeof data.name === 'string' ? data.name : (typeof data.login === 'string' ? data.login : email)
4397
+ if (provider === 'github' && !email) {
4398
+ const emails = (await (await fetch('https://api.github.com/user/emails', { headers })).json()) as Array<{ email: string; primary: boolean; verified: boolean }>
4399
+ email = (emails.find((e) => e.primary && e.verified) ?? emails.find((e) => e.verified))?.email ?? ''
4400
+ }
4401
+ return email ? { email: email.toLowerCase(), name: name || email } : null
4402
+ }
4369
4403
  `;
4370
- const oauthLoginServerTs = `import { error, redirect } from '@sveltejs/kit'
4371
- import type { RequestHandler } from './$types'
4372
- import { providerConfig, randomToken, pkceChallenge, type Provider } from '../lib/server/oauth'
4373
-
4374
- const PROVIDERS = ${providerSet}
4375
-
4376
- export const GET: RequestHandler = async ({ params, url, cookies }) => {
4377
- const provider = params.provider as Provider
4378
- if (!PROVIDERS.has(provider)) throw error(404)
4379
- const cfg = await providerConfig(provider)
4380
- if (!cfg.clientId) throw error(500, provider + ' sign-in is not configured (set ' + provider.toUpperCase() + '_CLIENT_ID).')
4381
- const secure = url.protocol === 'https:'
4382
- const state = randomToken()
4383
- cookies.set('oauth_state', state, { path: '/', httpOnly: true, sameSite: 'lax', maxAge: 600, secure })
4384
- const q = new URLSearchParams({ client_id: cfg.clientId, redirect_uri: url.origin + '/auth/' + provider + '/callback', response_type: 'code', scope: cfg.scope, state })
4385
- if (cfg.pkce) {
4386
- const verifier = randomToken()
4387
- cookies.set('oauth_verifier', verifier, { path: '/', httpOnly: true, sameSite: 'lax', maxAge: 600, secure })
4388
- q.set('code_challenge', await pkceChallenge(verifier))
4389
- q.set('code_challenge_method', 'S256')
4390
- }
4391
- throw redirect(302, cfg.endpoints.authorize + '?' + q.toString())
4392
- }
4404
+ const oauthLoginServerTs = `import { error, redirect } from '@sveltejs/kit'
4405
+ import type { RequestHandler } from './$types'
4406
+ import { providerConfig, randomToken, pkceChallenge, type Provider } from '../lib/server/oauth'
4407
+
4408
+ const PROVIDERS = ${providerSet}
4409
+
4410
+ export const GET: RequestHandler = async ({ params, url, cookies }) => {
4411
+ const provider = params.provider as Provider
4412
+ if (!PROVIDERS.has(provider)) throw error(404)
4413
+ const cfg = await providerConfig(provider)
4414
+ if (!cfg.clientId) throw error(500, provider + ' sign-in is not configured (set ' + provider.toUpperCase() + '_CLIENT_ID).')
4415
+ const secure = url.protocol === 'https:'
4416
+ const state = randomToken()
4417
+ cookies.set('oauth_state', state, { path: '/', httpOnly: true, sameSite: 'lax', maxAge: 600, secure })
4418
+ const q = new URLSearchParams({ client_id: cfg.clientId, redirect_uri: url.origin + '/auth/' + provider + '/callback', response_type: 'code', scope: cfg.scope, state })
4419
+ if (cfg.pkce) {
4420
+ const verifier = randomToken()
4421
+ cookies.set('oauth_verifier', verifier, { path: '/', httpOnly: true, sameSite: 'lax', maxAge: 600, secure })
4422
+ q.set('code_challenge', await pkceChallenge(verifier))
4423
+ q.set('code_challenge_method', 'S256')
4424
+ }
4425
+ throw redirect(302, cfg.endpoints.authorize + '?' + q.toString())
4426
+ }
4393
4427
  `;
4394
- const oauthCallbackServerTs = `import { error, redirect } from '@sveltejs/kit'
4395
- import type { RequestHandler } from './$types'
4396
- import { providerConfig, fetchProfile, type Provider } from '../lib/server/oauth'
4397
- import { findUser, createUser } from '../lib/server/users'
4398
- import { SESSION_COOKIE, SESSION_MAX_AGE, signSession } from '../lib/server/auth'
4399
-
4400
- const PROVIDERS = ${providerSet}
4401
-
4402
- export const GET: RequestHandler = async ({ params, url, cookies }) => {
4403
- const provider = params.provider as Provider
4404
- if (!PROVIDERS.has(provider)) throw error(404)
4405
- const code = url.searchParams.get('code')
4406
- const state = url.searchParams.get('state')
4407
- if (!code || !state || state !== cookies.get('oauth_state')) throw error(400, 'Invalid OAuth state.')
4408
- const cfg = await providerConfig(provider)
4409
- const body = new URLSearchParams({ client_id: cfg.clientId, client_secret: cfg.clientSecret, code, redirect_uri: url.origin + '/auth/' + provider + '/callback', grant_type: 'authorization_code' })
4410
- const verifier = cookies.get('oauth_verifier')
4411
- if (cfg.pkce && verifier) body.set('code_verifier', verifier)
4412
- const tokenRes = await fetch(cfg.endpoints.token, { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' }, body })
4413
- if (!tokenRes.ok) throw error(502, 'OAuth token exchange failed.')
4414
- const token = (await tokenRes.json()) as { access_token?: string }
4415
- if (!token.access_token) throw error(502, 'No access token returned.')
4416
- const profile = await fetchProfile(provider, token.access_token, cfg.endpoints.userinfo)
4417
- if (!profile) throw error(502, 'Could not read a verified email from the provider.')
4418
- cookies.delete('oauth_state', { path: '/' })
4419
- cookies.delete('oauth_verifier', { path: '/' })
4420
- let user = await findUser(profile.email)
4421
- if (!user) user = await createUser({ email: profile.email, name: profile.name, password: crypto.randomUUID() + crypto.randomUUID(), role: ${JSON.stringify(registerRole)} })
4422
- if (!user) throw error(500, 'Could not create your account.')
4423
- cookies.set(SESSION_COOKIE, await signSession({ email: user.email, name: user.name, role: user.role }), {
4424
- path: '/', httpOnly: true, sameSite: 'lax', secure: url.protocol === 'https:', maxAge: SESSION_MAX_AGE,
4425
- })
4426
- throw redirect(302, '/')
4427
- }
4428
+ const oauthCallbackServerTs = `import { error, redirect } from '@sveltejs/kit'
4429
+ import type { RequestHandler } from './$types'
4430
+ import { providerConfig, fetchProfile, type Provider } from '../lib/server/oauth'
4431
+ import { findUser, createUser } from '../lib/server/users'
4432
+ import { SESSION_COOKIE, SESSION_MAX_AGE, signSession } from '../lib/server/auth'
4433
+
4434
+ const PROVIDERS = ${providerSet}
4435
+
4436
+ export const GET: RequestHandler = async ({ params, url, cookies }) => {
4437
+ const provider = params.provider as Provider
4438
+ if (!PROVIDERS.has(provider)) throw error(404)
4439
+ const code = url.searchParams.get('code')
4440
+ const state = url.searchParams.get('state')
4441
+ if (!code || !state || state !== cookies.get('oauth_state')) throw error(400, 'Invalid OAuth state.')
4442
+ const cfg = await providerConfig(provider)
4443
+ const body = new URLSearchParams({ client_id: cfg.clientId, client_secret: cfg.clientSecret, code, redirect_uri: url.origin + '/auth/' + provider + '/callback', grant_type: 'authorization_code' })
4444
+ const verifier = cookies.get('oauth_verifier')
4445
+ if (cfg.pkce && verifier) body.set('code_verifier', verifier)
4446
+ const tokenRes = await fetch(cfg.endpoints.token, { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' }, body })
4447
+ if (!tokenRes.ok) throw error(502, 'OAuth token exchange failed.')
4448
+ const token = (await tokenRes.json()) as { access_token?: string }
4449
+ if (!token.access_token) throw error(502, 'No access token returned.')
4450
+ const profile = await fetchProfile(provider, token.access_token, cfg.endpoints.userinfo)
4451
+ if (!profile) throw error(502, 'Could not read a verified email from the provider.')
4452
+ cookies.delete('oauth_state', { path: '/' })
4453
+ cookies.delete('oauth_verifier', { path: '/' })
4454
+ let user = await findUser(profile.email)
4455
+ if (!user) user = await createUser({ email: profile.email, name: profile.name, password: crypto.randomUUID() + crypto.randomUUID(), role: ${JSON.stringify(registerRole)} })
4456
+ if (!user) throw error(500, 'Could not create your account.')
4457
+ cookies.set(SESSION_COOKIE, await signSession({ email: user.email, name: user.name, role: user.role }), {
4458
+ path: '/', httpOnly: true, sameSite: 'lax', secure: url.protocol === 'https:', maxAge: SESSION_MAX_AGE,
4459
+ })
4460
+ throw redirect(302, '/')
4461
+ }
4428
4462
  `;
4429
4463
  // NOTE: SESSION_SECRET is added to the shared .env.example by envExample() (which
4430
4464
  // scans the generated source), so it merges with DATABASE_URL etc. - no duplicate file.
@@ -4666,79 +4700,79 @@ function dataLayerFiles(project, sqlEntities, sources, includeUsers = false, use
4666
4700
  a.imports.forEach((i) => colImports.add(i));
4667
4701
  tableBlocks.push(a.block);
4668
4702
  }
4669
- const schemaTs = `// Regenerated by SvGrid Studio. Typed database schema (Drizzle ORM) - the source of
4670
- // truth for migrations: edit here, then run \`npm run db:generate\` && \`npm run db:migrate\`.
4671
- import { ${[...colImports].sort().join(', ')} } from '${cfg.core}'
4672
-
4673
- ${tableBlocks.join('\n\n')}
4703
+ const schemaTs = `// Regenerated by SvGrid Studio. Typed database schema (Drizzle ORM) - the source of
4704
+ // truth for migrations: edit here, then run \`npm run db:generate\` && \`npm run db:migrate\`.
4705
+ import { ${[...colImports].sort().join(', ')} } from '${cfg.core}'
4706
+
4707
+ ${tableBlocks.join('\n\n')}
4674
4708
  `;
4675
- const indexTs = `// Regenerated by SvGrid Studio. The Drizzle client (reads DATABASE_URL).
4676
- ${cfg.client}
4677
- import { env } from '$env/dynamic/private'
4678
- import * as schema from './schema'
4679
-
4680
- ${cfg.setup}
4681
- export { schema }
4709
+ const indexTs = `// Regenerated by SvGrid Studio. The Drizzle client (reads DATABASE_URL).
4710
+ ${cfg.client}
4711
+ import { env } from '$env/dynamic/private'
4712
+ import * as schema from './schema'
4713
+
4714
+ ${cfg.setup}
4715
+ export { schema }
4682
4716
  `;
4683
4717
  const repoFiles = meta.map(({ e, tableVar, pkKey, pkNumber }) => {
4684
4718
  const type = namesFor(e).type;
4685
4719
  const idT = pkNumber ? 'number' : 'string';
4686
4720
  // MySQL has no RETURNING, so create/update re-select the row after writing.
4687
4721
  const crud = cfg.returning
4688
- ? `export const ${tableVar}Repo = {
4689
- list: (): Promise<${type}Row[]> => db.select().from(${tableVar}),
4690
- get: async (id: ${idT}): Promise<${type}Row | undefined> =>
4691
- (await db.select().from(${tableVar}).where(eq(${tableVar}.${pkKey}, id))).at(0),
4692
- create: async (values: ${type}New): Promise<${type}Row> =>
4693
- (await db.insert(${tableVar}).values(values).returning()).at(0)!,
4694
- update: async (id: ${idT}, values: Partial<${type}New>): Promise<${type}Row | undefined> =>
4695
- (await db.update(${tableVar}).set(values).where(eq(${tableVar}.${pkKey}, id)).returning()).at(0),
4696
- remove: async (id: ${idT}): Promise<void> => {
4697
- await db.delete(${tableVar}).where(eq(${tableVar}.${pkKey}, id))
4698
- },
4722
+ ? `export const ${tableVar}Repo = {
4723
+ list: (): Promise<${type}Row[]> => db.select().from(${tableVar}),
4724
+ get: async (id: ${idT}): Promise<${type}Row | undefined> =>
4725
+ (await db.select().from(${tableVar}).where(eq(${tableVar}.${pkKey}, id))).at(0),
4726
+ create: async (values: ${type}New): Promise<${type}Row> =>
4727
+ (await db.insert(${tableVar}).values(values).returning()).at(0)!,
4728
+ update: async (id: ${idT}, values: Partial<${type}New>): Promise<${type}Row | undefined> =>
4729
+ (await db.update(${tableVar}).set(values).where(eq(${tableVar}.${pkKey}, id)).returning()).at(0),
4730
+ remove: async (id: ${idT}): Promise<void> => {
4731
+ await db.delete(${tableVar}).where(eq(${tableVar}.${pkKey}, id))
4732
+ },
4699
4733
  }`
4700
- : `async function getById(id: ${idT}): Promise<${type}Row | undefined> {
4701
- return (await db.select().from(${tableVar}).where(eq(${tableVar}.${pkKey}, id))).at(0)
4702
- }
4703
-
4704
- export const ${tableVar}Repo = {
4705
- list: (): Promise<${type}Row[]> => db.select().from(${tableVar}),
4706
- get: getById,
4707
- // MySQL: no RETURNING - re-select by the new/insertId after the write.
4708
- create: async (values: ${type}New): Promise<${type}Row> => {
4709
- const res = await db.insert(${tableVar}).values(values)
4710
- const id = ((values as Record<string, unknown>).${pkKey} ?? (res as unknown as Array<{ insertId: number }>)[0]?.insertId) as ${idT}
4711
- return (await getById(id))!
4712
- },
4713
- update: async (id: ${idT}, values: Partial<${type}New>): Promise<${type}Row | undefined> => {
4714
- await db.update(${tableVar}).set(values).where(eq(${tableVar}.${pkKey}, id))
4715
- return getById(id)
4716
- },
4717
- remove: async (id: ${idT}): Promise<void> => {
4718
- await db.delete(${tableVar}).where(eq(${tableVar}.${pkKey}, id))
4719
- },
4734
+ : `async function getById(id: ${idT}): Promise<${type}Row | undefined> {
4735
+ return (await db.select().from(${tableVar}).where(eq(${tableVar}.${pkKey}, id))).at(0)
4736
+ }
4737
+
4738
+ export const ${tableVar}Repo = {
4739
+ list: (): Promise<${type}Row[]> => db.select().from(${tableVar}),
4740
+ get: getById,
4741
+ // MySQL: no RETURNING - re-select by the new/insertId after the write.
4742
+ create: async (values: ${type}New): Promise<${type}Row> => {
4743
+ const res = await db.insert(${tableVar}).values(values)
4744
+ const id = ((values as Record<string, unknown>).${pkKey} ?? (res as unknown as Array<{ insertId: number }>)[0]?.insertId) as ${idT}
4745
+ return (await getById(id))!
4746
+ },
4747
+ update: async (id: ${idT}, values: Partial<${type}New>): Promise<${type}Row | undefined> => {
4748
+ await db.update(${tableVar}).set(values).where(eq(${tableVar}.${pkKey}, id))
4749
+ return getById(id)
4750
+ },
4751
+ remove: async (id: ${idT}): Promise<void> => {
4752
+ await db.delete(${tableVar}).where(eq(${tableVar}.${pkKey}, id))
4753
+ },
4720
4754
  }`;
4721
4755
  return {
4722
4756
  path: `src/lib/server/db/${namesFor(e).route}.ts`,
4723
4757
  description: `Typed repository for ${namesFor(e).label} (Drizzle).`,
4724
- contents: `// Regenerated by SvGrid Studio. Typed CRUD over the ${tableVar} table - call from
4725
- // server code, form actions, or your own API routes.
4726
- import { eq } from 'drizzle-orm'
4727
- import { db } from './index'
4728
- import { ${tableVar}, type ${type}Row, type ${type}New } from './schema'
4729
-
4730
- ${crud}
4758
+ contents: `// Regenerated by SvGrid Studio. Typed CRUD over the ${tableVar} table - call from
4759
+ // server code, form actions, or your own API routes.
4760
+ import { eq } from 'drizzle-orm'
4761
+ import { db } from './index.js'
4762
+ import { ${tableVar}, type ${type}Row, type ${type}New } from './schema'
4763
+
4764
+ ${crud}
4731
4765
  `,
4732
4766
  };
4733
4767
  });
4734
- const configTs = `import { defineConfig } from 'drizzle-kit'
4735
-
4736
- export default defineConfig({
4737
- schema: './src/lib/server/db/schema.ts',
4738
- out: './drizzle',
4739
- dialect: '${cfg.kit}',
4740
- ${cfg.creds},
4741
- })
4768
+ const configTs = `import { defineConfig } from 'drizzle-kit'
4769
+
4770
+ export default defineConfig({
4771
+ schema: './src/lib/server/db/schema.ts',
4772
+ out: './drizzle',
4773
+ dialect: '${cfg.kit}',
4774
+ ${cfg.creds},
4775
+ })
4742
4776
  `;
4743
4777
  // db/seed.ts - inserts the sample rows into EMPTY tables after `db:migrate`, so a
4744
4778
  // freshly-migrated production database doesn't open onto blank grids. Lives outside
@@ -4770,28 +4804,28 @@ export default defineConfig({
4770
4804
  });
4771
4805
  if (!rows.length)
4772
4806
  return null;
4773
- return ` {
4774
- const existing = await db.select().from(schema.${tableVar}).limit(1)
4775
- if (existing.length === 0) {
4776
- await db.insert(schema.${tableVar}).values(${JSON.stringify(rows)} as (typeof schema.${tableVar}.$inferInsert)[])
4777
- console.log('seeded ${e.name} (${rows.length} rows)')
4778
- } else console.log('${e.name} already has rows - skipped')
4807
+ return ` {
4808
+ const existing = await db.select().from(schema.${tableVar}).limit(1)
4809
+ if (existing.length === 0) {
4810
+ await db.insert(schema.${tableVar}).values(${JSON.stringify(rows)} as (typeof schema.${tableVar}.$inferInsert)[])
4811
+ console.log('seeded ${e.name} (${rows.length} rows)')
4812
+ } else console.log('${e.name} already has rows - skipped')
4779
4813
  }`;
4780
4814
  })
4781
4815
  .filter((b) => b !== null);
4782
- const seedTs = `// Regenerated by SvGrid Studio. studio:db-seed
4783
- // Inserts sample rows into EMPTY tables - a table that already has rows is skipped,
4784
- // so this is safe to run repeatedly. Run AFTER migrations: npm run db:seed
4785
- ${cfg.client}
4786
- import * as schema from '../src/lib/server/db/schema'
4787
-
4788
- ${cfg.setup.replaceAll('env.', 'process.env.').replace('export const db', 'const db')}
4789
-
4790
- async function main() {
4791
- ${seedBlocks.join('\n')}
4792
- }
4793
-
4794
- main().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1) })
4816
+ const seedTs = `// Regenerated by SvGrid Studio. studio:db-seed
4817
+ // Inserts sample rows into EMPTY tables - a table that already has rows is skipped,
4818
+ // so this is safe to run repeatedly. Run AFTER migrations: npm run db:seed
4819
+ ${cfg.client}
4820
+ import * as schema from '../src/lib/server/db/schema'
4821
+
4822
+ ${cfg.setup.replaceAll('env.', 'process.env.').replace('export const db', 'const db')}
4823
+
4824
+ async function main() {
4825
+ ${seedBlocks.join('\n')}
4826
+ }
4827
+
4828
+ main().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1) })
4795
4829
  `;
4796
4830
  return [
4797
4831
  { path: 'src/lib/server/db/schema.ts', description: 'Drizzle schema (migration source of truth).', contents: schemaTs },
@@ -4843,108 +4877,108 @@ const SCAFFOLD_STATIC = [
4843
4877
  * are emitted, so the fragment (which drops the layout) and the full app are
4844
4878
  * themed alike. `layer` is for drop-in outputs - see `themeTokenCss`. */
4845
4879
  function appCss(project, opts = {}) {
4846
- return `${themeTokenCss(project.theme, opts)}
4880
+ return `${themeTokenCss(project.theme, opts)}
4847
4881
  ${APP_CSS_BODY}`;
4848
4882
  }
4849
- const APP_CSS_BODY = `* { box-sizing: border-box; }
4850
- html, body { margin: 0; height: 100%; }
4851
- body { font-family: var(--sg-font, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif); color: var(--sg-fg, #0f172a); background: var(--sg-bg, #fff); }
4852
- .st__title { margin: 0; font-size: 22px; font-weight: 720; letter-spacing: -0.015em; }
4853
- .st__sub { margin: 0; font-size: 14px; line-height: 1.6; color: var(--sg-muted, #64748b); max-width: 74ch; }
4854
- .st__sub code { background: var(--sg-header-bg, #f1f5f9); padding: 1px 6px; border-radius: 5px; font-size: 0.9em; }
4855
- .st__toolbar { display: flex; align-items: center; gap: 10px; }
4856
- .st-hint { font-size: 12.5px; color: var(--sg-muted, #94a3b8); }
4857
- .st-error { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 0 0 16px; padding: 11px 14px; border: 1px solid color-mix(in srgb, var(--sg-danger, #dc2626) 40%, var(--sg-border, #e6e8ec)); border-radius: 10px; background: color-mix(in srgb, var(--sg-danger, #dc2626) 8%, var(--sg-bg, #fff)); color: var(--sg-danger, #b3261e); font-size: 13.5px; }
4858
- .st-btn { display: inline-flex; align-items: center; gap: 7px; padding: 8px 14px; font: inherit; font-size: 13.5px; font-weight: 560; line-height: 1; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 10px; background: var(--sg-bg, #fff); color: var(--sg-fg, inherit); cursor: pointer; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); }
4859
- .st-btn:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 5%, var(--sg-bg, #fff)); }
4860
- .st-btn:disabled { opacity: 0.5; cursor: default; box-shadow: none; }
4861
- .st-btn--primary { border-color: transparent; color: #fff; background: linear-gradient(180deg, color-mix(in srgb, var(--sg-accent) 88%, #fff), var(--sg-accent)); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.14), 0 8px 18px -9px color-mix(in srgb, var(--sg-accent) 65%, transparent); }
4862
- .st-btn--primary:hover { filter: brightness(1.06); }
4863
- .home { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; margin-top: 6px; }
4864
- .home__card { display: flex; flex-direction: column; gap: 6px; padding: 18px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 14px; text-decoration: none; color: inherit; background: var(--sg-bg, #fff); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); }
4865
- .home__card:hover { border-color: color-mix(in srgb, var(--sg-accent) 45%, var(--sg-border, #e6e8ec)); }
4866
- .home__card strong { font-size: 15px; }
4867
- .home__card span { font-size: 13px; color: var(--sg-muted, #64748b); line-height: 1.5; }
4868
- .st-filter { display: flex; flex-direction: column; gap: 10px; padding: 14px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; background: var(--sg-bg, #fff); align-self: start; }
4869
- .st-filter__title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--sg-muted, #64748b); }
4870
- .st-filter__row { display: flex; flex-direction: column; gap: 4px; font-size: 12.5px; font-weight: 600; color: var(--sg-muted, #64748b); }
4871
- .st-filter__row select, .st-filter__row input { padding: 7px 9px; font: inherit; font-size: 13px; font-weight: 400; color: var(--sg-fg, inherit); background: var(--sg-input-bg, var(--sg-bg, #fff)); border: 1px solid var(--sg-input-border, var(--sg-border, #e6e8ec)); border-radius: 8px; }
4872
- .st-record-card { border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; background: var(--sg-bg, #fff); padding: 14px; align-self: start; }
4873
- .st-record { margin: 0; display: flex; flex-direction: column; gap: 8px; }
4874
- .st-record__row { display: grid; grid-template-columns: 40% 1fr; gap: 10px; align-items: baseline; border-bottom: 1px solid var(--sg-border, #f1f5f9); padding-bottom: 6px; }
4875
- .st-record__row dt { margin: 0; font-size: 12px; font-weight: 600; color: var(--sg-muted, #64748b); }
4876
- .st-record__row dd { margin: 0; font-size: 13.5px; color: var(--sg-fg, inherit); overflow-wrap: anywhere; }
4877
- .st-screen { display: grid; grid-template-columns: repeat(12, 1fr); gap: 16px; align-items: start; }
4878
- /* Mobile: blocks stack full-width (a span-N block clamps to the single column). */
4879
- @media (max-width: 720px) { .st-screen { grid-template-columns: 1fr; gap: 12px; } }
4880
- /* Stack layout: every block full-width in a single flowing column (mobile-first). */
4881
- .st-stack { display: flex; flex-direction: column; gap: 16px; align-items: stretch; }
4882
- .st-stack > * { min-width: 0; width: 100%; }
4883
- @media (max-width: 720px) { .st-stack { gap: 12px; } }
4884
- /* Free-form canvas: blocks placed on a 12-column x fixed-row grid by cell coords. */
4885
- .st-canvas { display: grid; grid-template-columns: repeat(12, 1fr); grid-auto-rows: ${CANVAS_ROW_PX}px; gap: ${CANVAS_GAP_PX}px; align-items: stretch; }
4886
- .st-canvas__cell { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
4887
- .st-canvas__cell > * { flex: 1; min-height: 0; }
4888
- /* Narrow: an absolute canvas can't reflow, so fall back to a single stacked column. */
4889
- @media (max-width: 720px) {
4890
- .st-canvas { display: flex; flex-direction: column; gap: 12px; }
4891
- .st-canvas__cell { grid-column: auto !important; grid-row: auto !important; min-height: ${CANVAS_ROW_PX * 4}px; }
4892
- }
4893
- /* Docking workspace: the SvDockManager fills a sized region (it owns its own scrolling). */
4894
- .st-dock { height: min(74vh, 900px); min-height: 420px; }
4895
- @media (max-width: 640px) { .st__title { font-size: 19px; } }
4896
- .st-rowactions { display: inline-flex; gap: 6px; }
4897
- .st-rowaction { padding: 3px 9px; font: inherit; font-size: 12px; font-weight: 550; line-height: 1.4; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 7px; background: var(--sg-bg, #fff); color: var(--sg-fg, inherit); cursor: pointer; }
4898
- .st-rowaction:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 5%, var(--sg-bg, #fff)); }
4899
- .st-rowaction--danger { color: #dc2626; border-color: color-mix(in srgb, #dc2626 40%, var(--sg-border, #e6e8ec)); }
4900
- .st-rowaction--danger:hover { background: color-mix(in srgb, #dc2626 8%, var(--sg-bg, #fff)); }
4901
- .st-cell-link { color: var(--sg-accent, #4f46e5); text-decoration: none; }
4902
- .st-cell-link:hover { text-decoration: underline; }
4903
- .st-cell-progress { display: flex; align-items: center; min-width: 80px; width: 100%; }
4904
- .st-grid-toolbar { display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; margin-bottom: 8px; }
4905
- .st-treecell { display: inline-flex; align-items: center; gap: 4px; }
4906
- .st-tree-toggle { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; padding: 0; border: none; background: none; color: var(--sg-muted, #94a3b8); font-size: 11px; cursor: pointer; border-radius: 4px; }
4907
- .st-tree-toggle:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 8%, transparent); color: var(--sg-fg, inherit); }
4908
- .st-tree-spacer { display: inline-block; width: 18px; }
4883
+ const APP_CSS_BODY = `* { box-sizing: border-box; }
4884
+ html, body { margin: 0; height: 100%; }
4885
+ body { font-family: var(--sg-font, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif); color: var(--sg-fg, #0f172a); background: var(--sg-bg, #fff); }
4886
+ .st__title { margin: 0; font-size: 22px; font-weight: 720; letter-spacing: -0.015em; }
4887
+ .st__sub { margin: 0; font-size: 14px; line-height: 1.6; color: var(--sg-muted, #64748b); max-width: 74ch; }
4888
+ .st__sub code { background: var(--sg-header-bg, #f1f5f9); padding: 1px 6px; border-radius: 5px; font-size: 0.9em; }
4889
+ .st__toolbar { display: flex; align-items: center; gap: 10px; }
4890
+ .st-hint { font-size: 12.5px; color: var(--sg-muted, #94a3b8); }
4891
+ .st-error { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 0 0 16px; padding: 11px 14px; border: 1px solid color-mix(in srgb, var(--sg-danger, #dc2626) 40%, var(--sg-border, #e6e8ec)); border-radius: 10px; background: color-mix(in srgb, var(--sg-danger, #dc2626) 8%, var(--sg-bg, #fff)); color: var(--sg-danger, #b3261e); font-size: 13.5px; }
4892
+ .st-btn { display: inline-flex; align-items: center; gap: 7px; padding: 8px 14px; font: inherit; font-size: 13.5px; font-weight: 560; line-height: 1; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 10px; background: var(--sg-bg, #fff); color: var(--sg-fg, inherit); cursor: pointer; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); }
4893
+ .st-btn:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 5%, var(--sg-bg, #fff)); }
4894
+ .st-btn:disabled { opacity: 0.5; cursor: default; box-shadow: none; }
4895
+ .st-btn--primary { border-color: transparent; color: #fff; background: linear-gradient(180deg, color-mix(in srgb, var(--sg-accent) 88%, #fff), var(--sg-accent)); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.14), 0 8px 18px -9px color-mix(in srgb, var(--sg-accent) 65%, transparent); }
4896
+ .st-btn--primary:hover { filter: brightness(1.06); }
4897
+ .home { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; margin-top: 6px; }
4898
+ .home__card { display: flex; flex-direction: column; gap: 6px; padding: 18px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 14px; text-decoration: none; color: inherit; background: var(--sg-bg, #fff); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); }
4899
+ .home__card:hover { border-color: color-mix(in srgb, var(--sg-accent) 45%, var(--sg-border, #e6e8ec)); }
4900
+ .home__card strong { font-size: 15px; }
4901
+ .home__card span { font-size: 13px; color: var(--sg-muted, #64748b); line-height: 1.5; }
4902
+ .st-filter { display: flex; flex-direction: column; gap: 10px; padding: 14px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; background: var(--sg-bg, #fff); align-self: start; }
4903
+ .st-filter__title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--sg-muted, #64748b); }
4904
+ .st-filter__row { display: flex; flex-direction: column; gap: 4px; font-size: 12.5px; font-weight: 600; color: var(--sg-muted, #64748b); }
4905
+ .st-filter__row select, .st-filter__row input { padding: 7px 9px; font: inherit; font-size: 13px; font-weight: 400; color: var(--sg-fg, inherit); background: var(--sg-input-bg, var(--sg-bg, #fff)); border: 1px solid var(--sg-input-border, var(--sg-border, #e6e8ec)); border-radius: 8px; }
4906
+ .st-record-card { border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; background: var(--sg-bg, #fff); padding: 14px; align-self: start; }
4907
+ .st-record { margin: 0; display: flex; flex-direction: column; gap: 8px; }
4908
+ .st-record__row { display: grid; grid-template-columns: 40% 1fr; gap: 10px; align-items: baseline; border-bottom: 1px solid var(--sg-border, #f1f5f9); padding-bottom: 6px; }
4909
+ .st-record__row dt { margin: 0; font-size: 12px; font-weight: 600; color: var(--sg-muted, #64748b); }
4910
+ .st-record__row dd { margin: 0; font-size: 13.5px; color: var(--sg-fg, inherit); overflow-wrap: anywhere; }
4911
+ .st-screen { display: grid; grid-template-columns: repeat(12, 1fr); gap: 16px; align-items: start; }
4912
+ /* Mobile: blocks stack full-width (a span-N block clamps to the single column). */
4913
+ @media (max-width: 720px) { .st-screen { grid-template-columns: 1fr; gap: 12px; } }
4914
+ /* Stack layout: every block full-width in a single flowing column (mobile-first). */
4915
+ .st-stack { display: flex; flex-direction: column; gap: 16px; align-items: stretch; }
4916
+ .st-stack > * { min-width: 0; width: 100%; }
4917
+ @media (max-width: 720px) { .st-stack { gap: 12px; } }
4918
+ /* Free-form canvas: blocks placed on a 12-column x fixed-row grid by cell coords. */
4919
+ .st-canvas { display: grid; grid-template-columns: repeat(12, 1fr); grid-auto-rows: ${CANVAS_ROW_PX}px; gap: ${CANVAS_GAP_PX}px; align-items: stretch; }
4920
+ .st-canvas__cell { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
4921
+ .st-canvas__cell > * { flex: 1; min-height: 0; }
4922
+ /* Narrow: an absolute canvas can't reflow, so fall back to a single stacked column. */
4923
+ @media (max-width: 720px) {
4924
+ .st-canvas { display: flex; flex-direction: column; gap: 12px; }
4925
+ .st-canvas__cell { grid-column: auto !important; grid-row: auto !important; min-height: ${CANVAS_ROW_PX * 4}px; }
4926
+ }
4927
+ /* Docking workspace: the SvDockManager fills a sized region (it owns its own scrolling). */
4928
+ .st-dock { height: min(74vh, 900px); min-height: 420px; }
4929
+ @media (max-width: 640px) { .st__title { font-size: 19px; } }
4930
+ .st-rowactions { display: inline-flex; gap: 6px; }
4931
+ .st-rowaction { padding: 3px 9px; font: inherit; font-size: 12px; font-weight: 550; line-height: 1.4; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 7px; background: var(--sg-bg, #fff); color: var(--sg-fg, inherit); cursor: pointer; }
4932
+ .st-rowaction:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 5%, var(--sg-bg, #fff)); }
4933
+ .st-rowaction--danger { color: #dc2626; border-color: color-mix(in srgb, #dc2626 40%, var(--sg-border, #e6e8ec)); }
4934
+ .st-rowaction--danger:hover { background: color-mix(in srgb, #dc2626 8%, var(--sg-bg, #fff)); }
4935
+ .st-cell-link { color: var(--sg-accent, #4f46e5); text-decoration: none; }
4936
+ .st-cell-link:hover { text-decoration: underline; }
4937
+ .st-cell-progress { display: flex; align-items: center; min-width: 80px; width: 100%; }
4938
+ .st-grid-toolbar { display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; margin-bottom: 8px; }
4939
+ .st-treecell { display: inline-flex; align-items: center; gap: 4px; }
4940
+ .st-tree-toggle { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; padding: 0; border: none; background: none; color: var(--sg-muted, #94a3b8); font-size: 11px; cursor: pointer; border-radius: 4px; }
4941
+ .st-tree-toggle:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 8%, transparent); color: var(--sg-fg, inherit); }
4942
+ .st-tree-spacer { display: inline-block; width: 18px; }
4909
4943
  `;
4910
4944
  // NOTE: the generated app ships without a package-lock.json, so the workflows use
4911
4945
  // `npm install` (not `npm ci`) and omit setup-node's lockfile-dependent `cache: npm`
4912
4946
  // - both hard-fail when no lockfile is present. Commit a lockfile later to speed CI up.
4913
4947
  /** Universal CI: install + build + smoke tests on every push / PR. */
4914
- const CI_WORKFLOW = `name: CI
4915
- on:
4916
- push:
4917
- branches: [main]
4918
- pull_request:
4919
- branches: [main]
4920
- jobs:
4921
- build:
4922
- runs-on: ubuntu-latest
4923
- steps:
4924
- - uses: actions/checkout@v4
4925
- - uses: actions/setup-node@v4
4926
- with:
4927
- node-version: 20
4928
- - run: npm install
4929
- - run: npm run build
4930
- - run: npm test
4948
+ const CI_WORKFLOW = `name: CI
4949
+ on:
4950
+ push:
4951
+ branches: [main]
4952
+ pull_request:
4953
+ branches: [main]
4954
+ jobs:
4955
+ build:
4956
+ runs-on: ubuntu-latest
4957
+ steps:
4958
+ - uses: actions/checkout@v4
4959
+ - uses: actions/setup-node@v4
4960
+ with:
4961
+ node-version: 20
4962
+ - run: npm install
4963
+ - run: npm run build
4964
+ - run: npm test
4931
4965
  `;
4932
4966
  /** Wrap deploy steps in the standard checkout + node + install prelude. */
4933
4967
  function deployWorkflow(steps) {
4934
- return `name: Deploy
4935
- on:
4936
- push:
4937
- branches: [main]
4938
- workflow_dispatch:
4939
- jobs:
4940
- deploy:
4941
- runs-on: ubuntu-latest
4942
- steps:
4943
- - uses: actions/checkout@v4
4944
- - uses: actions/setup-node@v4
4945
- with:
4946
- node-version: 20
4947
- - run: npm install
4968
+ return `name: Deploy
4969
+ on:
4970
+ push:
4971
+ branches: [main]
4972
+ workflow_dispatch:
4973
+ jobs:
4974
+ deploy:
4975
+ runs-on: ubuntu-latest
4976
+ steps:
4977
+ - uses: actions/checkout@v4
4978
+ - uses: actions/setup-node@v4
4979
+ with:
4980
+ node-version: 20
4981
+ - run: npm install
4948
4982
  ${steps}`;
4949
4983
  }
4950
4984
  function deployPlan(project) {
@@ -4961,17 +4995,17 @@ function deployPlan(project) {
4961
4995
  dashboard: 'https://vercel.com/new',
4962
4996
  label: 'Vercel',
4963
4997
  secrets: ['VERCEL_TOKEN', 'VERCEL_ORG_ID', 'VERCEL_PROJECT_ID'],
4964
- deployWorkflow: deployWorkflow(` - name: Deploy to Vercel
4965
- if: \${{ secrets.VERCEL_TOKEN != '' }}
4966
- env:
4967
- VERCEL_TOKEN: \${{ secrets.VERCEL_TOKEN }}
4968
- VERCEL_ORG_ID: \${{ secrets.VERCEL_ORG_ID }}
4969
- VERCEL_PROJECT_ID: \${{ secrets.VERCEL_PROJECT_ID }}
4970
- run: |
4971
- npm i -g vercel
4972
- vercel pull --yes --environment=production --token="$VERCEL_TOKEN"
4973
- vercel build --prod --token="$VERCEL_TOKEN"
4974
- vercel deploy --prebuilt --prod --token="$VERCEL_TOKEN"
4998
+ deployWorkflow: deployWorkflow(` - name: Deploy to Vercel
4999
+ if: \${{ secrets.VERCEL_TOKEN != '' }}
5000
+ env:
5001
+ VERCEL_TOKEN: \${{ secrets.VERCEL_TOKEN }}
5002
+ VERCEL_ORG_ID: \${{ secrets.VERCEL_ORG_ID }}
5003
+ VERCEL_PROJECT_ID: \${{ secrets.VERCEL_PROJECT_ID }}
5004
+ run: |
5005
+ npm i -g vercel
5006
+ vercel pull --yes --environment=production --token="$VERCEL_TOKEN"
5007
+ vercel build --prod --token="$VERCEL_TOKEN"
5008
+ vercel deploy --prebuilt --prod --token="$VERCEL_TOKEN"
4975
5009
  `),
4976
5010
  };
4977
5011
  case 'netlify':
@@ -4984,12 +5018,12 @@ function deployPlan(project) {
4984
5018
  dashboard: 'https://app.netlify.com/start',
4985
5019
  label: 'Netlify',
4986
5020
  secrets: ['NETLIFY_AUTH_TOKEN', 'NETLIFY_SITE_ID'],
4987
- deployWorkflow: deployWorkflow(` - name: Deploy to Netlify
4988
- if: \${{ secrets.NETLIFY_AUTH_TOKEN != '' }}
4989
- env:
4990
- NETLIFY_AUTH_TOKEN: \${{ secrets.NETLIFY_AUTH_TOKEN }}
4991
- NETLIFY_SITE_ID: \${{ secrets.NETLIFY_SITE_ID }}
4992
- run: npx netlify deploy --build --prod
5021
+ deployWorkflow: deployWorkflow(` - name: Deploy to Netlify
5022
+ if: \${{ secrets.NETLIFY_AUTH_TOKEN != '' }}
5023
+ env:
5024
+ NETLIFY_AUTH_TOKEN: \${{ secrets.NETLIFY_AUTH_TOKEN }}
5025
+ NETLIFY_SITE_ID: \${{ secrets.NETLIFY_SITE_ID }}
5026
+ run: npx netlify deploy --build --prod
4993
5027
  `),
4994
5028
  };
4995
5029
  case 'cloudflare':
@@ -5002,13 +5036,13 @@ function deployPlan(project) {
5002
5036
  dashboard: 'https://dash.cloudflare.com/?to=/:account/pages/new',
5003
5037
  label: 'Cloudflare Pages',
5004
5038
  secrets: ['CLOUDFLARE_API_TOKEN', 'CLOUDFLARE_ACCOUNT_ID'],
5005
- deployWorkflow: deployWorkflow(` - run: npm run build
5006
- - name: Deploy to Cloudflare Pages
5007
- if: \${{ secrets.CLOUDFLARE_API_TOKEN != '' }}
5008
- env:
5009
- CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
5010
- CLOUDFLARE_ACCOUNT_ID: \${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
5011
- run: npx wrangler pages deploy .svelte-kit/cloudflare --project-name=${slug}
5039
+ deployWorkflow: deployWorkflow(` - run: npm run build
5040
+ - name: Deploy to Cloudflare Pages
5041
+ if: \${{ secrets.CLOUDFLARE_API_TOKEN != '' }}
5042
+ env:
5043
+ CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
5044
+ CLOUDFLARE_ACCOUNT_ID: \${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
5045
+ run: npx wrangler pages deploy .svelte-kit/cloudflare --project-name=${slug}
5012
5046
  `),
5013
5047
  };
5014
5048
  case 'node':
@@ -5286,40 +5320,40 @@ function smokeTestFile(project) {
5286
5320
  });
5287
5321
  const blocks = project.entities.map((e) => {
5288
5322
  const n = namesFor(e);
5289
- return `describe(${JSON.stringify(n.label)}, () => {
5290
- it('exposes grid columns and form fields', () => {
5291
- expect(schemaToColumns(${n.schemaVar}).length).toBeGreaterThan(0)
5292
- expect(schemaToFormFields(${n.schemaVar}).length).toBeGreaterThan(0)
5293
- })
5294
-
5295
- it('round-trips create -> read -> delete through an in-memory source', async () => {
5296
- const idField = ${n.schemaVar}.idField ?? ${n.schemaVar}.fields.find((f) => f.primaryKey)?.field ?? 'id'
5297
- const source = createInMemoryDataSource<${n.type}>([], ${n.schemaVar})
5298
- await source.createRow({ [idField]: 'smoke-1' } as unknown as Partial<${n.type}>)
5299
- expect((await source.getRows({ startRow: 0, endRow: 100, pageIndex: 0, pageSize: 100, sortModel: [], filterModel: {} })).rowCount).toBe(1)
5300
- await source.deleteRow('smoke-1')
5301
- expect((await source.getRows({ startRow: 0, endRow: 100, pageIndex: 0, pageSize: 100, sortModel: [], filterModel: {} })).rowCount).toBe(0)
5302
- })
5323
+ return `describe(${JSON.stringify(n.label)}, () => {
5324
+ it('exposes grid columns and form fields', () => {
5325
+ expect(schemaToColumns(${n.schemaVar}).length).toBeGreaterThan(0)
5326
+ expect(schemaToFormFields(${n.schemaVar}).length).toBeGreaterThan(0)
5327
+ })
5328
+
5329
+ it('round-trips create -> read -> delete through an in-memory source', async () => {
5330
+ const idField = ${n.schemaVar}.idField ?? ${n.schemaVar}.fields.find((f) => f.primaryKey)?.field ?? 'id'
5331
+ const source = createInMemoryDataSource<${n.type}>([], ${n.schemaVar})
5332
+ await source.createRow({ [idField]: 'smoke-1' } as unknown as Partial<${n.type}>)
5333
+ expect((await source.getRows({ startRow: 0, endRow: 100, pageIndex: 0, pageSize: 100, sortModel: [], filterModel: {} })).rowCount).toBe(1)
5334
+ await source.deleteRow('smoke-1')
5335
+ expect((await source.getRows({ startRow: 0, endRow: 100, pageIndex: 0, pageSize: 100, sortModel: [], filterModel: {} })).rowCount).toBe(0)
5336
+ })
5303
5337
  })`;
5304
5338
  });
5305
- return `// Smoke tests generated by SvGrid Studio. Run with \`npm test\`.
5306
- // They prove every entity's schema still renders (grid columns + form fields)
5307
- // and round-trips through the data-source layer, so a schema edit that would
5308
- // break a screen fails here first. Regenerating the app refreshes this file.
5309
- import { describe, it, expect } from 'vitest'
5310
- import { schemaToColumns, schemaToFormFields, createInMemoryDataSource } from '@svgrid/enterprise'
5311
- import { ${imports.join(', ')} } from './schemas'
5312
-
5313
- ${blocks.join('\n\n')}
5339
+ return `// Smoke tests generated by SvGrid Studio. Run with \`npm test\`.
5340
+ // They prove every entity's schema still renders (grid columns + form fields)
5341
+ // and round-trips through the data-source layer, so a schema edit that would
5342
+ // break a screen fails here first. Regenerating the app refreshes this file.
5343
+ import { describe, it, expect } from 'vitest'
5344
+ import { schemaToColumns, schemaToFormFields, createInMemoryDataSource } from '@svgrid/enterprise'
5345
+ import { ${imports.join(', ')} } from './schemas'
5346
+
5347
+ ${blocks.join('\n\n')}
5314
5348
  `;
5315
5349
  }
5316
- const VITEST_CONFIG = `import { defineConfig } from 'vitest/config'
5317
-
5318
- // Node-only test runner for the generated smoke tests (no Svelte/DOM needed).
5319
- // Kept separate from vite.config so the SvelteKit plugin doesn't load here.
5320
- export default defineConfig({
5321
- test: { environment: 'node', include: ['src/**/*.test.ts'] },
5322
- })
5350
+ const VITEST_CONFIG = `import { defineConfig } from 'vitest/config'
5351
+
5352
+ // Node-only test runner for the generated smoke tests (no Svelte/DOM needed).
5353
+ // Kept separate from vite.config so the SvelteKit plugin doesn't load here.
5354
+ export default defineConfig({
5355
+ test: { environment: 'node', include: ['src/**/*.test.ts'] },
5356
+ })
5323
5357
  `;
5324
5358
  /**
5325
5359
  * Emit the COMPLETE runnable SvelteKit + Vite app: the generated screens/data
@@ -5349,38 +5383,38 @@ function cronScheduleFiles(project, plan) {
5349
5383
  contents: JSON.stringify({ crons }, null, 2) + '\n',
5350
5384
  }];
5351
5385
  }
5352
- const steps = jobs.map((j) => ` - name: ${j.name} (${j.cron})
5353
- if: \${{ github.event_name == 'workflow_dispatch' || github.event.schedule == '${j.cron}' }}
5354
- run: |
5355
- curl -fsS -X POST "$CRON_URL?job=${encodeURIComponent(j.id)}" \\
5356
- -H "authorization: Bearer $CRON_SECRET"
5386
+ const steps = jobs.map((j) => ` - name: ${j.name} (${j.cron})
5387
+ if: \${{ github.event_name == 'workflow_dispatch' || github.event.schedule == '${j.cron}' }}
5388
+ run: |
5389
+ curl -fsS -X POST "$CRON_URL?job=${encodeURIComponent(j.id)}" \\
5390
+ -H "authorization: Bearer $CRON_SECRET"
5357
5391
  `).join('');
5358
5392
  const schedules = [...new Set(jobs.map((j) => j.cron))].map((c) => ` - cron: '${c}'`).join('\n');
5359
5393
  return [{
5360
5394
  path: '.github/workflows/cron.yml',
5361
5395
  description: 'Scheduled job runner: calls /api/cron on the deployed app.',
5362
- contents: `# Scheduled jobs for the generated app. GitHub runs this on the schedules
5363
- # below and it calls the app's /api/cron endpoint.
5364
- #
5365
- # Set two repository secrets before it does anything:
5366
- # CRON_URL https://<your-app>/api/cron
5367
- # CRON_SECRET the same value as the app's CRON_SECRET env var
5368
- # Until then the job short-circuits, so CI stays green.
5369
- name: Scheduled jobs
5370
-
5371
- on:
5372
- schedule:
5373
- ${schedules}
5374
- workflow_dispatch:
5375
-
5376
- jobs:
5377
- cron:
5378
- runs-on: ubuntu-latest
5379
- if: \${{ secrets.CRON_URL != '' && secrets.CRON_SECRET != '' }}
5380
- env:
5381
- CRON_URL: \${{ secrets.CRON_URL }}
5382
- CRON_SECRET: \${{ secrets.CRON_SECRET }}
5383
- steps:
5396
+ contents: `# Scheduled jobs for the generated app. GitHub runs this on the schedules
5397
+ # below and it calls the app's /api/cron endpoint.
5398
+ #
5399
+ # Set two repository secrets before it does anything:
5400
+ # CRON_URL https://<your-app>/api/cron
5401
+ # CRON_SECRET the same value as the app's CRON_SECRET env var
5402
+ # Until then the job short-circuits, so CI stays green.
5403
+ name: Scheduled jobs
5404
+
5405
+ on:
5406
+ schedule:
5407
+ ${schedules}
5408
+ workflow_dispatch:
5409
+
5410
+ jobs:
5411
+ cron:
5412
+ runs-on: ubuntu-latest
5413
+ if: \${{ secrets.CRON_URL != '' && secrets.CRON_SECRET != '' }}
5414
+ env:
5415
+ CRON_URL: \${{ secrets.CRON_URL }}
5416
+ CRON_SECRET: \${{ secrets.CRON_SECRET }}
5417
+ steps:
5384
5418
  ${steps}`,
5385
5419
  }];
5386
5420
  }
@@ -5436,39 +5470,39 @@ export function emitStudioFragment(project) {
5436
5470
  const depList = Object.entries(deps).map(([name, v]) => ` ${name}@${v}`).join('\n');
5437
5471
  const envKeys = envKeysUsed(allSource);
5438
5472
  const libFiles = content.filter((f) => f.path.startsWith('src/lib/')).map((f) => `- \`${f.path}\``);
5439
- const fragmentMd = `# Studio fragment - drop into your SvelteKit app
5440
-
5441
- This folder holds ONLY app content - routes under \`src/routes\` and modules under
5442
- \`src/lib\` (plus \`db/\` if present). It has no package.json, config, or app shell,
5443
- so it drops into your existing SvelteKit app.
5444
-
5445
- ## 1. Copy the files
5446
- Merge \`src/routes/*\` and \`src/lib/*\` into your app's \`src/\`. \`$lib\` is a stock
5447
- SvelteKit alias, so imports resolve with no config. Watch for filename collisions
5448
- with your own \`src/lib\` (e.g. \`schemas.ts\`, \`data.ts\`) - rename if needed.
5449
-
5450
- ## 2. Install dependencies
5451
- \`\`\`bash
5452
- npm install ${Object.keys(deps).filter((d) => d !== '@svgrid/grid' && d !== '@svgrid/enterprise').join(' ') || '@svgrid/grid @svgrid/enterprise'}
5453
- \`\`\`
5454
- Full list this fragment imports:
5455
- \`\`\`
5456
- ${depList}
5457
- \`\`\`
5458
-
5459
- ## 3. Styles
5460
- Import \`src/app.css\` once (e.g. in your root \`+layout.svelte\`) - it carries the
5461
- \`.st-*\` page styles the screens use and the \`--sg-*\` design tokens of the theme
5462
- picked in Studio. The tokens sit in a cascade layer (\`@layer svgrid-studio\`), so if
5463
- your app already defines its own \`--sg-*\` tokens, yours keep winning.
5464
-
5465
- ## 4. What your app must provide
5466
- The full-app nav shell, home redirect, auth guard, RBAC bootstrap, i18n provider,
5467
- and theme toggle live in the bundle's \`+layout.svelte\` - NOT here. Wire nav to the
5468
- generated routes from your own layout.${envKeys.length ? `\n\n## Environment\nSet these where your app reads env:\n${envKeys.map((k) => `- \`${k}\``).join('\n')}` : ''}
5469
-
5470
- ## Files included
5471
- ${libFiles.join('\n')}
5473
+ const fragmentMd = `# Studio fragment - drop into your SvelteKit app
5474
+
5475
+ This folder holds ONLY app content - routes under \`src/routes\` and modules under
5476
+ \`src/lib\` (plus \`db/\` if present). It has no package.json, config, or app shell,
5477
+ so it drops into your existing SvelteKit app.
5478
+
5479
+ ## 1. Copy the files
5480
+ Merge \`src/routes/*\` and \`src/lib/*\` into your app's \`src/\`. \`$lib\` is a stock
5481
+ SvelteKit alias, so imports resolve with no config. Watch for filename collisions
5482
+ with your own \`src/lib\` (e.g. \`schemas.ts\`, \`data.ts\`) - rename if needed.
5483
+
5484
+ ## 2. Install dependencies
5485
+ \`\`\`bash
5486
+ npm install ${Object.keys(deps).filter((d) => d !== '@svgrid/grid' && d !== '@svgrid/enterprise').join(' ') || '@svgrid/grid @svgrid/enterprise'}
5487
+ \`\`\`
5488
+ Full list this fragment imports:
5489
+ \`\`\`
5490
+ ${depList}
5491
+ \`\`\`
5492
+
5493
+ ## 3. Styles
5494
+ Import \`src/app.css\` once (e.g. in your root \`+layout.svelte\`) - it carries the
5495
+ \`.st-*\` page styles the screens use and the \`--sg-*\` design tokens of the theme
5496
+ picked in Studio. The tokens sit in a cascade layer (\`@layer svgrid-studio\`), so if
5497
+ your app already defines its own \`--sg-*\` tokens, yours keep winning.
5498
+
5499
+ ## 4. What your app must provide
5500
+ The full-app nav shell, home redirect, auth guard, RBAC bootstrap, i18n provider,
5501
+ and theme toggle live in the bundle's \`+layout.svelte\` - NOT here. Wire nav to the
5502
+ generated routes from your own layout.${envKeys.length ? `\n\n## Environment\nSet these where your app reads env:\n${envKeys.map((k) => `- \`${k}\``).join('\n')}` : ''}
5503
+
5504
+ ## Files included
5505
+ ${libFiles.join('\n')}
5472
5506
  `;
5473
5507
  return [
5474
5508
  ...content,