@svgrid/enterprise 2.2.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +95 -81
  2. package/dist/cdn/svgrid-enterprise.svelte-external.js +22468 -10459
  3. package/dist/node/studio.js +11590 -2907
  4. package/package.json +31 -10
  5. package/src/SvAlertRuleEditor.svelte +294 -0
  6. package/src/SvAlertsManager.svelte +210 -0
  7. package/src/SvAlertsPanel.svelte +129 -0
  8. package/src/SvBoard.svelte +3 -1
  9. package/src/SvExpressionEditor.svelte +341 -0
  10. package/src/SvGridAlerts.dom.test.ts +113 -0
  11. package/src/SvGridAlerts.svelte +265 -0
  12. package/src/SvGridBoard.svelte +2355 -0
  13. package/src/SvGridEditPanel.svelte +849 -794
  14. package/src/SvGridScheduler.svelte +5334 -4410
  15. package/src/SvPivotDesigner.svelte +3 -3
  16. package/src/SvRecordDetail.svelte +6 -2
  17. package/src/SvSchedule.svelte +3 -1
  18. package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +4 -1
  19. package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +4 -1
  20. package/src/{ai-export.test.ts → ai-export.dom.test.ts} +5 -1
  21. package/src/alerts/alert-engine-attach.ts +165 -0
  22. package/src/alerts/alert-engine.test.ts +135 -0
  23. package/src/alerts/alert-engine.ts +260 -0
  24. package/src/alerts/alert-formats.test.ts +87 -0
  25. package/src/alerts/alert-formats.ts +77 -0
  26. package/src/alerts/alert-observer.test.ts +189 -0
  27. package/src/alerts/alert-observer.ts +208 -0
  28. package/src/alerts/alert-scheduler.ts +96 -0
  29. package/src/alerts/alert-storage.test.ts +54 -0
  30. package/src/alerts/alert-storage.ts +116 -0
  31. package/src/alerts/alert-store.svelte.ts +80 -0
  32. package/src/alerts/alert-types.ts +94 -0
  33. package/src/alerts.ts +28 -0
  34. package/src/board.dom.test.ts +941 -0
  35. package/src/board.ts +36 -0
  36. package/src/export.ts +6 -0
  37. package/src/expressions/evaluate.test.ts +111 -0
  38. package/src/expressions/evaluate.ts +228 -0
  39. package/src/expressions/expression-columns.ts +133 -0
  40. package/src/expressions/expression-types.ts +84 -0
  41. package/src/expressions/parse.test.ts +106 -0
  42. package/src/expressions/parse.ts +610 -0
  43. package/src/import.ts +2 -0
  44. package/src/index.ts +180 -46
  45. package/src/install.ts +12 -2
  46. package/src/pivot-enable.ts +44 -0
  47. package/src/scheduler-assignments.test.ts +97 -0
  48. package/src/scheduler-assignments.ts +134 -0
  49. package/src/scheduler-axis.test.ts +108 -0
  50. package/src/scheduler-axis.ts +238 -0
  51. package/src/scheduler-booking.test.ts +57 -0
  52. package/src/scheduler-booking.ts +63 -0
  53. package/src/scheduler-config.ts +179 -0
  54. package/src/scheduler-dependencies.test.ts +155 -0
  55. package/src/scheduler-dependencies.ts +223 -0
  56. package/src/scheduler-freebusy.test.ts +41 -0
  57. package/src/scheduler-freebusy.ts +36 -0
  58. package/src/scheduler-heatmap.test.ts +39 -0
  59. package/src/scheduler-heatmap.ts +55 -0
  60. package/src/scheduler-resource-tree.test.ts +84 -0
  61. package/src/scheduler-resource-tree.ts +107 -0
  62. package/src/scheduler-slots.test.ts +53 -0
  63. package/src/scheduler-slots.ts +94 -0
  64. package/src/scheduler-summary.test.ts +47 -0
  65. package/src/scheduler-summary.ts +81 -0
  66. package/src/sources/index.ts +1 -1
  67. package/src/sources/introspect-supabase.test.ts +13 -1
  68. package/src/sources/introspect-supabase.ts +20 -0
  69. package/src/studio/copilot-core.test.ts +45 -0
  70. package/src/studio/copilot-core.ts +65 -0
  71. package/src/studio/deploy-cli.test.ts +56 -0
  72. package/src/studio/deploy-cli.ts +100 -0
  73. package/src/studio/emit-project.test.ts +503 -18
  74. package/src/studio/emit-project.ts +829 -111
  75. package/src/studio/emit-schema.test.ts +17 -0
  76. package/src/studio/emit-schema.ts +166 -35
  77. package/src/studio/index.ts +25 -1
  78. package/src/studio/introspect-openapi.test.ts +84 -0
  79. package/src/studio/introspect-openapi.ts +252 -0
  80. package/src/studio/project.test.ts +57 -0
  81. package/src/studio/project.ts +193 -6
  82. package/src/studio/samples/crm.ts +282 -258
  83. package/src/studio/samples/fleet.ts +213 -186
  84. package/src/studio/samples/insurance.ts +223 -195
  85. package/src/studio/samples/inventory.ts +213 -182
  86. package/src/studio/samples/live-data.test.ts +99 -98
  87. package/src/studio/samples/live-data.ts +8 -10
  88. package/src/studio/samples/projects.ts +212 -190
  89. package/src/studio/samples/samples.test.ts +52 -0
  90. package/src/studio/samples/shared.ts +26 -6
  91. package/src/studio/samples/starter.ts +251 -0
  92. package/src/studio/samples/support.ts +209 -184
  93. package/src/studio/ui-components-surface.test.ts +185 -0
  94. package/src/studio/ui-components.generated.ts +5617 -0
  95. package/src/studio/ui-components.ts +641 -472
  96. package/src/sveltekit/sql-source.test.ts +13 -0
  97. package/src/sveltekit/sql-source.ts +11 -6
  98. package/src/upgrade-prompt.ts +2 -2
  99. package/src/watermark.ts +2 -2
  100. package/src/ai.test.ts +0 -522
  101. package/src/ai.ts +0 -1388
@@ -11,12 +11,12 @@
11
11
  * self-contained).
12
12
  */
13
13
  import type { GeneratedFile } from './scaffold.js'
14
- import type { ActionConfig, Block, ComponentBinding, ComponentConfig, EntityDataSource, FilterPanelConfig, GridColumnConfig, GridConfig, KpiConfig, OAuthProvider, PivotConfig, RecordConfig, RowAction, SchedulerViewConfig, Screen, StudioProject } from './project.js'
15
- import { blockColumns, blockStyleCss, blockClassName, sanitizeClassName, componentHandleName, componentHasBindings, entityDataSource, flattenBlocks, serializeProject, seedUsers, compileHandlerSteps, clickSlot, rowSelectSlot, changeSlot, FORM_SUBMIT, screenLayoutOf, isPaneLayout, canvasRectOf, CANVAS_ROW_PX, CANVAS_GAP_PX, gridOpts, stackOpts, splitOpts, dockOpts, canvasOpts, stateInitExpr, stateTsType, reconcileDock, ON_LOAD, ON_DESTROY } from './project.js'
16
- import { uiComponentSpec } from './ui-components.js'
14
+ import type { ActionConfig, Block, ComponentBinding, ComponentConfig, EntityDataSource, FilterPanelConfig, GridColumnConfig, GridConfig, KpiConfig, OAuthProvider, PivotConfig, RecordConfig, RowAction, SchedulerViewConfig, Screen, StudioProject, SupabaseSource } from './project.js'
15
+ import { blockColumns, blockStyleCss, blockClassName, sanitizeClassName, componentHandleName, componentHasBindings, entityDataSource, flattenBlocks, serializeProject, seedUsers, compileHandlerSteps, clickSlot, rowSelectSlot, changeSlot, eventSlot, FORM_SUBMIT, GRID_EVENTS, screenLayoutOf, isPaneLayout, canvasRectOf, CANVAS_ROW_PX, CANVAS_GAP_PX, gridOpts, stackOpts, splitOpts, dockOpts, canvasOpts, stateInitExpr, stateTsType, reconcileDock, ON_LOAD, ON_DESTROY, isSsrScreen, ssrScreenShape } from './project.js'
16
+ import { uiComponentSpec, gridApiSettableProps, STANDARD_UI_EVENTS } from './ui-components.js'
17
17
  import { resolveThemeTokens, resolveThemeTokensFor, isDarkTheme } from './themes.js'
18
18
  import type { EntityField, EntitySchema } from '../schema.js'
19
- import { emitEntityModules, homeFile, layoutFile, lookupVar, namesFor, relationDisplayFields, type NavItem } from './emit-schema.js'
19
+ import { emitEntityModules, homeFile, layoutFile, lookupVar, namesFor, prepareEntities, relationDisplayFields, sqlDdlFiles, type NavItem } from './emit-schema.js'
20
20
 
21
21
  const has = (blocks: Block[], kind: Block['config']['kind']) => blocks.some((b) => b.config.kind === kind)
22
22
 
@@ -148,7 +148,7 @@ function screenLayoutStyle(screen: Screen): string {
148
148
 
149
149
  /** Markup for one block inside the screen grid. `ctx.hasRecord` tells a grid to
150
150
  * publish its clicked row into `selectedRecord` for a sibling record panel. */
151
- function blockMarkup(entity: EntitySchema, schemaVar: string, typeName: string, block: Block, resolve: (name: string) => EntitySchema | undefined, ctx: { hasRecord: boolean; accessEnabled?: boolean; routeById?: Map<string, string>; i18n?: boolean; rawEntity?: EntitySchema; rawResolve?: (name: string) => EntitySchema | undefined; captureApi?: string; handleNames?: Map<string, string>; pane?: boolean; rowSelectSteps?: string; ctxLiteral?: string } = { hasRecord: false }): string {
151
+ function blockMarkup(entity: EntitySchema, schemaVar: string, typeName: string, block: Block, resolve: (name: string) => EntitySchema | undefined, ctx: { hasRecord: boolean; accessEnabled?: boolean; routeById?: Map<string, string>; i18n?: boolean; rawEntity?: EntitySchema; rawResolve?: (name: string) => EntitySchema | undefined; captureApi?: string; handleNames?: Map<string, string>; pane?: boolean; rowSelectSteps?: string; ctxLiteral?: string; gridEventSink?: string } = { hasRecord: false }): string {
152
152
  // A block's display label: localized via $t('block.<id>', 'literal') when i18n is on.
153
153
  const tLabel = (label: string, key: string) => (ctx.i18n ? `{$t('block.${key}', ${JSON.stringify(label)})}` : label)
154
154
  // In a dock pane the block fills its pane (the pane owns the size); in the 12-col grid it
@@ -187,6 +187,23 @@ function blockMarkup(entity: EntitySchema, schemaVar: string, typeName: string,
187
187
  const tree = gridHasTree(cfg)
188
188
  const grouped = !!cfg.grouping?.length && !tree
189
189
  const dataExpr = tree ? `tree_${idSafe}.visible` : grouped ? 'allRows' : 'view.rows'
190
+ // Code-behind can subscribe to grid events (`ctx.grid.onCellClick = fn`): each
191
+ // event prop fires into the grid handle. Feature-owned props (sort/filter/edit/
192
+ // paginate/row-click) COMPOSE the fire onto their built-in body; the rest are
193
+ // added in one pass below. `sink` is the grid handle var, '' when code is off.
194
+ const sink = ctx.gridEventSink ?? ''
195
+ const firedProps = new Set<string>()
196
+ const fireInto = (key: string, args: string): string => {
197
+ if (!sink) return ''
198
+ firedProps.add(GRID_EVENTS.find((e) => e.key === key)!.prop)
199
+ return `; ${sink}.fire('${key}', ${args})`
200
+ }
201
+ // Compose a built-in event handler with the user's ctx.grid subscription. Stays
202
+ // terse (no braces, no fire) when code is off, so non-code screens are unchanged.
203
+ const withFire = (param: string, base: string, key: string, fireArgs: string): string => {
204
+ const fire = fireInto(key, fireArgs)
205
+ return fire ? `(${param}) => { ${base}${fire} }` : `(${param}) => ${base}`
206
+ }
190
207
  const lines = [`data={${dataExpr}}`, `columns={${colVar}}`, `loading={${tree || grouped ? '!allRowsReady' : 'view.loading'}}`, `loadingOverlay`, `emptyMessage=${JSON.stringify(emptyMsg)}`, `fitColumns`]
191
208
  lines.push(`enableRowSummaries={${cfg.rowSummaries ? 'true' : 'false'}}`)
192
209
  if (cfg.striped) lines.push(`zebraRows`)
@@ -210,14 +227,19 @@ function blockMarkup(entity: EntitySchema, schemaVar: string, typeName: string,
210
227
  if (cfg.sortable) lines.push(`sortable`)
211
228
  if (cfg.filterable) lines.push(`filterable`, ...filterSurfaceProps(cfg))
212
229
  } else {
213
- if (cfg.sortable) lines.push(`sortable`, `externalSort`, `onSortingChange={(s) => controller.setSort(s)}`)
214
- if (cfg.filterable) lines.push(`filterable`, ...filterSurfaceProps(cfg), `externalFilter`, `onFiltersChange={(f) => controller.setFilter({ global: f.global || undefined, columns: Object.fromEntries(f.columns.map((c) => [c.id, { operator: c.operator, value: c.value, valueTo: c.valueTo, selectedValues: c.selectedValues }])) })}`)
230
+ if (cfg.sortable) lines.push(`sortable`, `externalSort`, `onSortingChange={${withFire('s', 'controller.setSort(s)', 'sortingChange', 's')}}`)
231
+ if (cfg.filterable) lines.push(`filterable`, ...filterSurfaceProps(cfg), `externalFilter`, `onFiltersChange={${withFire('f', `controller.setFilter({ global: f.global || undefined, columns: Object.fromEntries(f.columns.map((c) => [c.id, { operator: c.operator, value: c.value, valueTo: c.valueTo, selectedValues: c.selectedValues }])) })`, 'filtersChange', 'f')}}`)
215
232
  }
216
233
  // RBAC: gate the edit affordances on the update permission (server also enforces).
217
234
  const canUpdate = ctx.accessEnabled ? `can($currentRole, 'update')` : 'true'
218
- if (cfg.editing === 'form') lines.push(ctx.accessEnabled ? `onRowDoubleClick={(e) => { if (${canUpdate}) editing = e.row }}` : `onRowDoubleClick={(e) => (editing = e.row)}`)
235
+ if (cfg.editing === 'form') {
236
+ const fire = fireInto('rowDoubleClick', 'e')
237
+ lines.push(ctx.accessEnabled
238
+ ? `onRowDoubleClick={(e) => { if (${canUpdate}) editing = e.row${fire} }}`
239
+ : fire ? `onRowDoubleClick={(e) => { (editing = e.row)${fire} }}` : `onRowDoubleClick={(e) => (editing = e.row)}`)
240
+ }
219
241
  // Inline editing writes through the controller by row id (not offered for tree grids).
220
- if (cfg.editing === 'inline' && !tree) lines.push(`onCellValueChange={(e) => { ${ctx.accessEnabled ? `if (!${canUpdate}) return; ` : ''}const row = ${grouped ? 'allRows' : 'view.rows'}[e.rowIndex]; if (row) controller.updateRow(String((row as Record<string, unknown>)[idField]), { [e.columnId]: e.newValue } as Partial<${typeName}>) }}`)
242
+ if (cfg.editing === 'inline' && !tree) lines.push(`onCellValueChange={(e) => { ${ctx.accessEnabled ? `if (!${canUpdate}) return; ` : ''}const row = ${grouped ? 'allRows' : 'view.rows'}[e.rowIndex]; if (row) controller.updateRow(String((row as Record<string, unknown>)[idField]), { [e.columnId]: e.newValue } as Partial<${typeName}>)${fireInto('cellValueChange', 'e')} }}`)
221
243
  // Row click: drill-through (highest precedence), a record-panel selection,
222
244
  // and/or the user's "On row select" method steps (row-scoped ctx). When steps
223
245
  // are present they merge with the built-in action into one async handler.
@@ -229,23 +251,30 @@ function blockMarkup(entity: EntitySchema, schemaVar: string, typeName: string,
229
251
  if (ctx.rowSelectSteps) {
230
252
  const pre = primaryStmt ? `\n ${primaryStmt}` : ''
231
253
  const stepBody = ctx.rowSelectSteps.split('\n').map((l) => (l ? ' ' + l : l)).join('\n')
232
- lines.push(`onRowClick={async (e) => {${pre}\n const row = e.row\n const ctx = ${ctx.ctxLiteral} as unknown as PageContext\n${stepBody}\n }}`)
254
+ const fire = fireInto('rowClick', 'e')
255
+ lines.push(`onRowClick={async (e) => {${pre}\n const row = e.row\n const ctx = ${ctx.ctxLiteral} as unknown as PageContext\n${stepBody}${fire ? '\n ' + fire.slice(2) : ''}\n }}`)
233
256
  } else if (rowLinkStmt) {
234
- lines.push(`onRowClick={(e) => ${rowLinkStmt}}`)
257
+ lines.push(`onRowClick={${withFire('e', rowLinkStmt, 'rowClick', 'e')}}`)
235
258
  } else if (ctx.hasRecord) {
236
- lines.push(`onRowClick={(e) => (selectedRecord = e.row)}`)
259
+ lines.push(`onRowClick={${withFire('e', '(selectedRecord = e.row)', 'rowClick', 'e')}}`)
237
260
  }
238
261
  if (cfg.paginated !== false && !tree) {
239
262
  if (grouped) {
240
263
  // Client pagination: the grid slices `data` itself.
241
264
  lines.push(`showPagination`, `pageSize={${cfg.pageSize}}`)
242
265
  } else {
243
- lines.push(`showPagination`, `externalPagination`, `rowCount={view.total}`, `pageIndex={view.pageIndex}`, `pageSize={view.pageSize}`, `onPaginationChange={({ pageIndex, pageSize }) => (pageSize !== view.pageSize ? controller.setPageSize(pageSize) : controller.setPage(pageIndex))}`)
266
+ lines.push(`showPagination`, `externalPagination`, `rowCount={view.total}`, `pageIndex={view.pageIndex}`, `pageSize={view.pageSize}`, `onPaginationChange={${withFire('{ pageIndex, pageSize }', '(pageSize !== view.pageSize ? controller.setPageSize(pageSize) : controller.setPage(pageIndex))', 'paginationChange', '{ pageIndex, pageSize }')}}`)
244
267
  }
245
268
  if (cfg.paginationPosition && cfg.paginationPosition !== 'bottom') lines.push(`paginationPosition="${cfg.paginationPosition}"`)
246
269
  const opts = cfg.pageSizeOptions
247
270
  if (opts && opts.length && (opts.length !== 4 || opts.join(',') !== '10,25,50,100')) lines.push(`pageSizeOptions={[${opts.join(', ')}]}`)
248
271
  }
272
+ // Every remaining grid event -> fire into ctx.grid so code can subscribe
273
+ // (`ctx.grid.onCellClick = fn`). Feature-owned props already composed above.
274
+ if (sink) for (const ev of GRID_EVENTS) {
275
+ if (ev.dataEvent) continue // fired by the data controller, not a grid prop
276
+ if (!firedProps.has(ev.prop)) lines.push(`${ev.prop}={(...a) => ${sink}.fire('${ev.key}', ...a)}`)
277
+ }
249
278
  // No-code conditional formatting -> the grid's rule engine.
250
279
  const cf = conditionalFormatsExpr(cfg)
251
280
  if (cf) lines.push(`conditionalFormats={${cf}}`)
@@ -256,6 +285,15 @@ function blockMarkup(entity: EntitySchema, schemaVar: string, typeName: string,
256
285
  if (apiBody.length === 1 && ctx.captureApi && !grouped) lines.push(`onApiReady={(a) => (${ctx.captureApi} = a)}`)
257
286
  else if (apiBody.length) lines.push(`onApiReady={(a) => { ${apiBody.join('; ')} }}`)
258
287
  lines.push(`containerHeight=${ctx.pane ? '"100%"' : `{${block.height ?? 360}}`}`)
288
+ // Raw "All properties" overrides: pass through every grid prop the curated
289
+ // controls didn't already emit (deduped by prop name), so nothing is set twice.
290
+ if (cfg.props && Object.keys(cfg.props).length) {
291
+ const emitted = new Set(lines.map((l) => l.split(/[=\s]/)[0]))
292
+ for (const [k, v] of Object.entries(cfg.props)) {
293
+ if (v === undefined || emitted.has(k)) continue
294
+ lines.push(v === true ? k : `${k}={${JSON.stringify(v)}}`)
295
+ }
296
+ }
259
297
  // No-code export toolbar - buttons wired to the grid's own export API.
260
298
  const exportBar = gridHasExport(cfg) && ctx.captureApi ? exportToolbarMarkup(cfg.export!, ctx.captureApi, entity.name) : ''
261
299
  return ` <div ${span}${cls}>
@@ -296,10 +334,13 @@ ${exportBar} <SvGrid
296
334
  if (cfg.trendField) {
297
335
  const tReduce = cfg.trendReduce ?? cfg.reduce
298
336
  const seriesExpr = `kpiSeries(${rowsExpr}, { trendField: '${cfg.trendField}', ${measurePart}reduce: '${tReduce}' })`
337
+ // The first-to-last delta chip - only when there's no target chip already
338
+ // (avoids a dead `&& false` branch that left `_d` null-unsafe under svelte-check).
339
+ const deltaChip = cfg.target == null
340
+ ? `\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}`
341
+ : ''
299
342
  rows.push(` {#if ${seriesExpr}.length > 1}
300
- {@const _s = ${seriesExpr}}
301
- {@const _d = seriesDelta(_s)}
302
- {#if _d != null && ${cfg.target == null}}<span class="kpi__delta" class:is-up={_d >= 0} class:is-down={_d < 0}>{_d >= 0 ? '▲' : '▼'} {Math.abs(_d).toFixed(0)}%</span>{/if}
343
+ {@const _s = ${seriesExpr}}${deltaChip}
303
344
  <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>
304
345
  {/if}`)
305
346
  }
@@ -505,13 +546,23 @@ function componentBlockMarkup(block: Block, cfg: ComponentConfig, handleName?: s
505
546
  if (!spec) return ` <div ${span}${cls}><!-- unknown component "${cfg.component}" --></div>`
506
547
  if (handleName) {
507
548
  // Handle mode (code page): props + content come from the reactive handle, and
508
- // clicks fire on it - so button1.setVariant(...) / button1.onclick = fn work.
549
+ // events fire on it - so button1.setVariant(...) / button1.onclick = fn work.
509
550
  const inner = spec.hasContent ? `>{${handleName}.text}</${spec.importName}>` : ' />'
510
- // `change` bubbles up from any inner input, so the wrapper catches it too - that
511
- // powers the "On change" method slot without knowing the component's shape.
551
+ // Events with a component callback prop are wired THROUGH that prop (exact
552
+ // semantics, works for non-bubbling callbacks like a picker's onChange); the
553
+ // wrapper's DOM listeners remain only as the catch-all for click/change on
554
+ // components without a matching callback prop - and are skipped when a prop
555
+ // covers the same key, so an event never fires twice.
556
+ const propWired = (spec.events ?? []).filter((e) => e.prop)
557
+ const eventAttrs = propWired.map((e) => ` ${e.prop}={(e) => ${handleName}.fire(${jsStr(e.key)}, e)}`).join('')
558
+ // The wrapper forwards the full standard DOM event surface to the handle (VS-style
559
+ // events), skipping any a component callback prop already covers (no double-fire).
560
+ const wrapper = STANDARD_UI_EVENTS.filter((se) => !propWired.some((e) => e.key === se.key))
561
+ .map((se) => ` on${se.dom}={(e) => ${handleName}.fire('${se.key}', e)}`)
562
+ .join('')
512
563
  return ` <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
513
- <div id="${block.id}" onclick={(e) => ${handleName}.fire('click', e)} onchange={(e) => ${handleName}.fire('change', e)} ${span}${cls}>
514
- <${spec.importName} {...${handleName}.props}${inner}
564
+ <div id="${block.id}"${wrapper} ${span}${cls}>
565
+ <${spec.importName} {...${handleName}.props}${eventAttrs}${inner}
515
566
  </div>`
516
567
  }
517
568
  // Data bindings: a bound prop's value is a reactive expression over the screen's
@@ -520,13 +571,13 @@ function componentBlockMarkup(block: Block, cfg: ComponentConfig, handleName?: s
520
571
  const bound = (key: string): ComponentBinding | undefined => (rowsExpr ? bindings[key] : undefined)
521
572
  const attrs: string[] = []
522
573
  for (const p of spec.props) {
574
+ if (p.code) continue // code-only props (functions) are set via the handle, not literals
523
575
  const b = bound(p.key)
524
576
  if (b) { attrs.push(`${p.key}={${bindingExpr(b, rowsExpr!, p.type === 'number')}}`); continue }
525
577
  const v = cfg.props[p.key] ?? p.default
526
578
  if (v == null || v === '') continue
527
579
  if (p.type === 'boolean') { if (v) attrs.push(p.key) }
528
- else if (p.type === 'number') attrs.push(`${p.key}={${Number(v)}}`)
529
- else attrs.push(`${p.key}={${jsStr(String(v))}}`)
580
+ else attrs.push(`${p.key}={${propValueExpr(p, v)}}`)
530
581
  }
531
582
  // Baked-in array/object props (Timeline items, Sparkline data): emitted verbatim.
532
583
  for (const f of spec.fixed ?? []) attrs.push(`${f.key}={${f.expr}}`)
@@ -542,14 +593,37 @@ function componentBlockMarkup(block: Block, cfg: ComponentConfig, handleName?: s
542
593
  </div>`
543
594
  }
544
595
 
596
+ /** A JS expression for a component prop's configured value, by panel type. */
597
+ function propValueExpr(p: { type: string }, v: unknown): string {
598
+ if (p.type === 'number') return String(Number(v))
599
+ if (p.type === 'boolean') return String(!!v)
600
+ if (p.type === 'json') return JSON.stringify(v)
601
+ if (p.type === 'date') return `new Date(${jsStr(String(v))})`
602
+ return jsStr(String(v))
603
+ }
604
+
605
+ /** Extra SvGridEditPanel props for a grid's optional form-layout depth. Returns a
606
+ * leading-space attribute string (or '' when nothing is configured). */
607
+ function editPanelLayoutProps(grid?: GridConfig): string {
608
+ if (!grid) return ''
609
+ const attrs: string[] = []
610
+ if (grid.formColumns && grid.formColumns !== 2) attrs.push(`columns={${grid.formColumns}}`)
611
+ if (grid.formFields?.length) attrs.push(`formFields={${JSON.stringify(grid.formFields)}}`)
612
+ if (grid.formSections?.length) attrs.push(`sections={${JSON.stringify(grid.formSections)}}`)
613
+ if (grid.formTitle) attrs.push(`title={${jsStr(grid.formTitle)}}`)
614
+ if (grid.formSize && grid.formSize !== 'md') attrs.push(`formSize=${jsStr(grid.formSize)}`)
615
+ return attrs.length ? ' ' + attrs.join(' ') : ''
616
+ }
617
+
545
618
  /** The `{ props, text }` init literal for a component's reactive handle. */
546
619
  function handleInit(cfg: ComponentConfig): string {
547
620
  const spec = uiComponentSpec(cfg.component)
548
621
  const props: string[] = []
549
622
  for (const p of spec?.props ?? []) {
623
+ if (p.code) continue // code-only props are set via the handle in code, not seeded
550
624
  const v = cfg.props[p.key] ?? p.default
551
625
  if (v == null || v === '') continue
552
- props.push(`${p.key}: ${p.type === 'number' ? Number(v) : p.type === 'boolean' ? !!v : jsStr(String(v))}`)
626
+ props.push(`${p.key}: ${propValueExpr(p, v)}`)
553
627
  }
554
628
  for (const f of spec?.fixed ?? []) props.push(`${f.key}: ${f.expr}`)
555
629
  const parts = [`props: { ${props.join(', ')} }`]
@@ -636,14 +710,16 @@ function filterPanelState(entity: EntitySchema, block: Block, cfg: FilterPanelCo
636
710
  const { state, apply } = facetNames(block)
637
711
  const assigns = filterFieldsOf(entity, cfg).map((f) => {
638
712
  const key = jsStr(f.field)
639
- if (f.type === 'boolean') return ` if (v[${key}] === 'true' || v[${key}] === 'false') c[${key}] = { operator: 'equals', value: v[${key}] === 'true' }`
713
+ // The server filter model takes string values; the matcher string-coerces both
714
+ // sides, so a boolean column matches fine against 'true' / 'false'.
715
+ if (f.type === 'boolean') return ` if (v[${key}] === 'true' || v[${key}] === 'false') c[${key}] = { operator: 'equals', value: v[${key}] }`
640
716
  if (f.type === 'enum') return ` if (v[${key}]) c[${key}] = { operator: 'equals', value: v[${key}] }`
641
717
  return ` if (v[${key}]) c[${key}] = { operator: 'contains', value: v[${key}] }`
642
718
  }).join('\n')
643
719
  return `let ${state} = $state<Record<string, string>>({})
644
720
  function ${apply}() {
645
721
  const v = ${state}
646
- const c: Record<string, { operator: 'equals' | 'contains'; value: unknown }> = {}
722
+ const c: Record<string, { operator: 'equals' | 'contains'; value: string }> = {}
647
723
  ${assigns}
648
724
  controller.setFilter({ columns: c })
649
725
  }`
@@ -1025,6 +1101,27 @@ function handleNameMap(screen: Screen): Map<string, string> {
1025
1101
  return new Map(screenHandles(screen).map((h) => [h.blockId, h.name]))
1026
1102
  }
1027
1103
 
1104
+ /** The `on<Event>` setter surface added to `ctx.grid` so `ctx.grid.onCellClick = fn`
1105
+ * type-checks. The `Row` placeholder in GRID_EVENTS params is the screen's row type. */
1106
+ function gridEventsType(rowType: string): string {
1107
+ return `{ ${GRID_EVENTS.map((e) => `${e.method}?: (${e.params.replace(/\bRow\b/g, rowType)}) => void`).join('; ')} }`
1108
+ }
1109
+
1110
+ /** The runtime-settable prop surface added to `ctx.grid` so `ctx.grid.sortable = true`
1111
+ * (and `if (ctx.grid.zebraRows)`) type-check + autocomplete. Assignments route to the
1112
+ * grid's `setOption` (a reactive $state override); reads route to `getOption`. */
1113
+ function gridPropsType(): string {
1114
+ const tsType = (p: { type: string; options?: string[] }): string =>
1115
+ p.type === 'boolean' ? 'boolean'
1116
+ : p.type === 'number' ? 'number'
1117
+ : p.type === 'select' && p.options?.length ? p.options.map((o) => JSON.stringify(o)).join(' | ')
1118
+ : p.type === 'json' ? 'unknown'
1119
+ : 'string'
1120
+ const props = gridApiSettableProps()
1121
+ if (!props.length) return '{}'
1122
+ return `{ ${props.map((p) => `${p.key}?: ${tsType(p)}`).join('; ')} }`
1123
+ }
1124
+
1028
1125
  /** Does this code-enabled screen expose a `ctx.data` dataset battery, and can code
1029
1126
  * replace its rows? Freestanding data-grid pages own their rows (settable via
1030
1127
  * setRows); an entity screen with a Grid exposes its current page + reload(). */
@@ -1088,7 +1185,7 @@ export function ctxCompletions(screen: Screen): string[] {
1088
1185
  for (const h of screenHandles(screen)) {
1089
1186
  const base = `ctx.${h.name}`
1090
1187
  out.push(base)
1091
- if (h.tier === 'grid') out.push(...GRID_API_MEMBERS.map((m) => `${base}.${m}()`))
1188
+ if (h.tier === 'grid') out.push(...GRID_API_MEMBERS.map((m) => `${base}.${m}()`), ...gridApiSettableProps().map((p) => `${base}.${p.key}`))
1092
1189
  else if (h.tier === 'data') out.push(...DATA_HANDLE_MEMBERS.map((m) => `${base}.${m}`))
1093
1190
  else if (h.component) out.push(...componentHandleMembers(h.component).map((m) => `${base}.${m}`))
1094
1191
  }
@@ -1180,7 +1277,7 @@ export function ctxAmbientDts(screen: Screen, entity?: EntitySchema): string {
1180
1277
 
1181
1278
  const members: string[] = []
1182
1279
  for (const h of handles) {
1183
- if (h.tier === 'grid') members.push(` /** The Grid on this page - its full, real SvGridApi. */\n grid: SvGridApi<${rowName}>`)
1280
+ if (h.tier === 'grid') members.push(` /** The Grid on this page - its full, real SvGridApi, plus event subscription (grid.onRowClick = (e) => {}) and runtime options (grid.sortable = true). */\n grid: SvGridApi<${rowName}> & ${gridEventsType(rowName)} & ${gridPropsType()}`)
1184
1281
  else if (h.tier === 'data') members.push(` /** The ${h.kind} - feed it rows with ${h.name}.setData(rows). */\n ${h.name}: DataHandle<${rowName}>`)
1185
1282
  else members.push(` ${h.name}: ${componentHandleTypeName(h.component!)}`)
1186
1283
  }
@@ -1227,7 +1324,7 @@ ${members.join('\n')}
1227
1324
  function screenElementsManifest(screen: Screen): string {
1228
1325
  const lines: string[] = []
1229
1326
  const describe: Record<HandleTier, (h: BlockHandle) => string> = {
1230
- grid: () => "the Grid's full SvGridApi - exportCsv(), selectCells(), startEditing(), addRow(), setFilter(), ...",
1327
+ grid: () => "the Grid's full SvGridApi - exportCsv(), selectCells(), startEditing(), addRow(), setFilter(), ...; subscribe to events (grid.onRowClick = (e) => {}); set options live (grid.sortable = true).",
1231
1328
  data: (h) => `the ${h.kind} - setData(rows) to feed it your own rows, .rows to read them, clear() to follow the screen data again.`,
1232
1329
  component: (h) => `the ${h.component} - setText/set<Prop>(...), onclick = fn, onClick(fn).`,
1233
1330
  }
@@ -1266,9 +1363,9 @@ export class ComponentHandle {
1266
1363
  get(name: string): unknown { return this.props[name] }
1267
1364
  setText(value: string): this { this.text = value; return this }
1268
1365
  setLabel(value: string): this { this.text = value; return this }
1269
- onEvent(name: string, fn: (e: Event) => void): this { this.on = { ...this.on, [name]: fn }; return this }
1366
+ onEvent(name: string, fn: (e: Event) => void): this { this.on = { ...this.on, [name.toLowerCase()]: fn }; return this }
1270
1367
  onClick(fn: (e: Event) => void): this { return this.onEvent('click', fn) }
1271
- fire(name: string, e: Event): void { this.on[name]?.(e) }
1368
+ fire(name: string, e: Event): void { this.on[name.toLowerCase()]?.(e) }
1272
1369
  }
1273
1370
 
1274
1371
  /** An imperative, reactive handle over a data-bound block (chart, KPI, gauge,
@@ -1290,6 +1387,42 @@ export class DataHandle<T = Record<string, unknown>> {
1290
1387
  /** A DataHandle whose fallback is the screen dataset getter. */
1291
1388
  export function dataHandle<T>(fallback: () => T[]): DataHandle<T> { return new DataHandle<T>(fallback) }
1292
1389
 
1390
+ /** Event subscriptions for the Grid (\`ctx.grid.onCellClick = (e) => {}\`). The keys
1391
+ * are lowercased on both store + dispatch so any on<Event> casing round-trips. */
1392
+ class GridEvents {
1393
+ on: Record<string, (...args: unknown[]) => void> = {}
1394
+ fire(name: string, ...args: unknown[]): void { this.on[name.toLowerCase()]?.(...args) }
1395
+ }
1396
+ /** Wrap the Grid's real SvGridApi so page code gets BOTH its methods
1397
+ * (\`ctx.grid.exportCsv()\`) and event subscription (\`ctx.grid.onRowClick = fn\`).
1398
+ * The api arrives asynchronously (onApiReady), so it is read lazily via \`getApi\`. */
1399
+ export function gridHandle<A extends object>(getApi: () => A | null): A & GridEvents {
1400
+ const ev = new GridEvents()
1401
+ return new Proxy(ev, {
1402
+ get(t, k) {
1403
+ 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 }
1404
+ const api = getApi() as Record<string | symbol, unknown> | null
1405
+ if (!api) return undefined
1406
+ const v = api[k]
1407
+ if (typeof v === 'function') return (v as (...a: unknown[]) => unknown).bind(api)
1408
+ // A grid PROP read (ctx.grid.sortable) -> its effective value via getOption.
1409
+ if (v === undefined && typeof k === 'string' && !/^on[A-Z]/.test(k) && typeof api.getOption === 'function') return (api.getOption as (key: unknown) => unknown)(k)
1410
+ return v
1411
+ },
1412
+ set(t, k, val) {
1413
+ // onRowClick = fn / oncellclick = fn -> subscribe (any casing; keys lowercase).
1414
+ 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 }
1415
+ const api = getApi() as Record<string | symbol, unknown> | null
1416
+ // ctx.grid.sortable = true -> the grid's reactive option channel (setOption writes
1417
+ // a $state override the grid merges over its props). Falls back to a direct set for
1418
+ // an older grid build without setOption.
1419
+ if (api && typeof api.setOption === 'function') { (api.setOption as (key: unknown, value: unknown) => void)(k, val); return true }
1420
+ if (api) api[k] = val
1421
+ return true
1422
+ },
1423
+ }) as A & GridEvents
1424
+ }
1425
+
1293
1426
  /** A handle plus dynamic setX / onX helpers and \`el.onclick = fn\` assignment. */
1294
1427
  export type Handle = ComponentHandle & Record<string, any>
1295
1428
 
@@ -1299,11 +1432,13 @@ export function handle(init: { props?: Record<string, unknown>; text?: string })
1299
1432
  get(t, k) {
1300
1433
  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 }
1301
1434
  if (typeof k === 'string' && /^set[A-Z]/.test(k)) { const p = k[3]!.toLowerCase() + k.slice(4); return (v: unknown) => t.set(p, v) }
1302
- if (typeof k === 'string' && /^on[A-Z]/.test(k)) { const e = k[2]!.toLowerCase() + k.slice(3); return (fn: (ev: Event) => void) => t.onEvent(e, fn) }
1435
+ // onEvent(fn) call form. onEvent lowercases, so onKeyDown maps to the 'keydown' the wrapper fires.
1436
+ if (typeof k === 'string' && /^on[A-Z]/.test(k)) { const e = k.slice(2); return (fn: (ev: Event) => void) => t.onEvent(e, fn) }
1303
1437
  return typeof k === 'string' ? t.props[k] : undefined
1304
1438
  },
1305
1439
  set(t, k, v) {
1306
- if (typeof k === 'string' && /^on[a-z]/.test(k)) { t.onEvent(k.slice(2), v as (e: Event) => void); return true }
1440
+ // el.onKeyDown = fn / el.onclick = fn -> subscribe (onEvent lowercases the key).
1441
+ if (typeof k === 'string' && /^on[A-Za-z]/.test(k)) { t.onEvent(k.slice(2), v as (e: Event) => void); return true }
1307
1442
  if (k === 'text') { t.text = v as string; return true } // content, not a prop
1308
1443
  if (typeof k === 'string') t.set(k, v) // checkbox1.checked = true
1309
1444
  return true
@@ -1361,14 +1496,16 @@ function componentHandleTypeDecl(componentKey: string): string | null {
1361
1496
  * the PageContext type never drift. `rowType` is the entity's row type (entity
1362
1497
  * screens) or `RowData` (freestanding); `datasetRowsVar` is the state var backing
1363
1498
  * a settable `ctx.data`. */
1364
- function codeWiring(screen: Screen, rowType: string, datasetRowsVar: string | undefined): { decls: string[]; ctxLiteral: string; usesHandle: boolean; usesDataHandle: boolean } {
1499
+ function codeWiring(screen: Screen, rowType: string, datasetRowsVar: string | undefined): { decls: string[]; ctxLiteral: string; usesHandle: boolean; usesDataHandle: boolean; usesGridHandle: boolean } {
1365
1500
  const blockById = new Map(flattenBlocks(screen.blocks).map((b) => [b.id, b]))
1366
1501
  const decls: string[] = []
1367
1502
  const ctxParts: string[] = []
1368
1503
  let usesHandle = false
1369
1504
  let usesDataHandle = false
1505
+ let usesGridHandle = false
1370
1506
  for (const h of screenHandles(screen)) {
1371
- if (h.tier === 'grid') { ctxParts.push('grid: gridApi!'); continue }
1507
+ // ctx.grid = the real SvGridApi PLUS event subscription (ctx.grid.onRowClick = fn).
1508
+ if (h.tier === 'grid') { usesGridHandle = true; decls.push('const gridCtx = gridHandle(() => gridApi)'); ctxParts.push('grid: gridCtx'); continue }
1372
1509
  if (h.tier === 'data') {
1373
1510
  usesDataHandle = true
1374
1511
  decls.push(`const ${h.name} = dataHandle<${rowType}>(() => allRows)`)
@@ -1388,7 +1525,7 @@ function codeWiring(screen: Screen, rowType: string, datasetRowsVar: string | un
1388
1525
  ctxParts.push('goto')
1389
1526
  ctxParts.push('params: Object.fromEntries($page.url.searchParams)')
1390
1527
  if (screen.state?.length) ctxParts.push(`state: { ${screen.state.map((v) => `get ${v.name}() { return ${v.name} }, set ${v.name}(x) { ${v.name} = x }`).join(', ')} }`)
1391
- return { decls, ctxLiteral: `{ ${ctxParts.join(', ')} }`, usesHandle, usesDataHandle }
1528
+ return { decls, ctxLiteral: `{ ${ctxParts.join(', ')} }`, usesHandle, usesDataHandle, usesGridHandle }
1392
1529
  }
1393
1530
 
1394
1531
  /** Per-screen, regenerated PageContext type: the tiered handles (grid api / data
@@ -1404,7 +1541,7 @@ function screenContextFile(screen: Screen, rowType: string): GeneratedFile {
1404
1541
 
1405
1542
  const members: string[] = []
1406
1543
  for (const h of handles) {
1407
- if (h.tier === 'grid') members.push(` /** The Grid on this page - its full, real SvGridApi. */\n grid: SvGridApi<any, ${rowType}>`)
1544
+ if (h.tier === 'grid') members.push(` /** The Grid on this page - its full, real SvGridApi, plus event subscription (grid.onRowClick = (e) => {}) and runtime options (grid.sortable = true). */\n grid: SvGridApi<any, ${rowType}> & ${gridEventsType(rowType)} & ${gridPropsType()}`)
1408
1545
  else if (h.tier === 'data') members.push(` /** The ${h.kind} - feed it rows with ${h.name}.setData(rows); ${h.name}.rows reads them. */\n ${h.name}: DataHandle<${rowType}>`)
1409
1546
  else members.push(` ${h.name}: ${componentHandleTypeName(h.component!)}`)
1410
1547
  }
@@ -1445,27 +1582,25 @@ function compiledMethodBodies(screen: Screen): { onLoadSteps?: string; clicks?:
1445
1582
  if (!steps || !Object.keys(steps).length) return {}
1446
1583
  const names = handleNameMap(screen)
1447
1584
  const clicks: string[] = []
1585
+ // Legacy visual event steps compile to `ctx.<name>.on<event> = async () => { ... }`
1586
+ // inside onLoad. New event handlers are written as raw code directly in the onLoad
1587
+ // body (double-click an event -> Studio inserts `ctx.<name>.on<Event> = (e) => {}`).
1448
1588
  for (const b of flattenBlocks(screen.blocks)) {
1449
1589
  if (b.config.kind !== 'component') continue
1450
- const clickSteps = steps[clickSlot(b.id)]
1451
- if (!clickSteps?.length) continue
1452
1590
  const name = names.get(b.id)
1453
1591
  if (!name) continue
1454
- clicks.push(`ctx.${name}.onclick = async () => {\n${indentBody(compileHandlerSteps(clickSteps))}\n}`)
1455
- }
1456
- // Component value-change wiring (`ctx.<name>.onchange = ...`), same shape as clicks.
1457
- for (const b of flattenBlocks(screen.blocks)) {
1458
- if (b.config.kind !== 'component') continue
1459
- const changeSteps = steps[changeSlot(b.id)]
1460
- if (!changeSteps?.length) continue
1461
- const name = names.get(b.id)
1462
- if (!name) continue
1463
- clicks.push(`ctx.${name}.onchange = async () => {\n${indentBody(compileHandlerSteps(changeSteps))}\n}`)
1592
+ const spec = uiComponentSpec(b.config.component)
1593
+ const eventKeys = [...(spec?.events ?? []).map((e) => e.key), ...STANDARD_UI_EVENTS.map((e) => e.key)]
1594
+ for (const key of [...new Set(eventKeys)]) {
1595
+ const eventSteps = steps[eventSlot(key, b.id)]
1596
+ if (!eventSteps?.length) continue
1597
+ clicks.push(`ctx.${name}.on${key} = async () => {\n${indentBody(compileHandlerSteps(eventSteps))}\n}`)
1598
+ }
1464
1599
  }
1465
1600
  return {
1466
1601
  // Legacy visual onLoad steps (the code view now edits onLoad directly).
1467
1602
  onLoadSteps: steps[ON_LOAD]?.length ? compileHandlerSteps(steps[ON_LOAD]) : undefined,
1468
- // Component on-click wiring - MERGED with any hand-written onLoad body, never replacing it.
1603
+ // Component on-event wiring - MERGED with any hand-written onLoad body, never replacing it.
1469
1604
  clicks: clicks.length ? clicks.join('\n\n') : undefined,
1470
1605
  onDestroy: steps[ON_DESTROY]?.length ? compileHandlerSteps(steps[ON_DESTROY]) : undefined,
1471
1606
  }
@@ -1546,7 +1681,7 @@ function freestandingScreenPage(screen: Screen, accessEnabled: boolean, i18nEnab
1546
1681
  const gridImport = gridNames.length ? `import { ${gridNames.join(', ')} } from '@svgrid/grid'\n ` : ''
1547
1682
  const gridTypes = [grid ? 'RowData' : '', grid ? 'SvGridApi' : ''].filter(Boolean)
1548
1683
  const typeImport = hasCode && gridTypes.length ? `import type { ${gridTypes.join(', ')} } from '@svgrid/grid'\n ` : ''
1549
- const handleSpecs = [wiring?.usesHandle ? 'handle' : '', wiring?.usesDataHandle ? 'dataHandle' : ''].filter(Boolean)
1684
+ const handleSpecs = [wiring?.usesHandle ? 'handle' : '', wiring?.usesDataHandle ? 'dataHandle' : '', wiring?.usesGridHandle ? 'gridHandle' : ''].filter(Boolean)
1550
1685
  const handleImport = handleSpecs.length ? `import { ${handleSpecs.join(', ')} } from '$lib/handles.svelte'\n ` : ''
1551
1686
 
1552
1687
  const codeImport = hasCode ? `import { onMount } from 'svelte'\n import * as handlers from './handlers'\n import type { PageContext } from './page-context'\n ` : ''
@@ -1559,7 +1694,7 @@ function freestandingScreenPage(screen: Screen, accessEnabled: boolean, i18nEnab
1559
1694
  ? `\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 })) : [])`
1560
1695
  : ''
1561
1696
  const codeScript = hasCode
1562
- ? `\n ${handleDecls ? handleDecls + '\n ' : ''}${gridScript ? gridScript.trimStart() + '\n ' : ''}onMount(() => {
1697
+ ? `\n ${gridScript ? gridScript.trimStart() + '\n ' : ''}${handleDecls ? handleDecls + '\n ' : ''}onMount(() => {
1563
1698
  // ctx is internal plumbing wired from this screen's blocks; the typed surface your
1564
1699
  // code uses lives in handlers.ts (PageContext). Component handles are runtime proxies,
1565
1700
  // so the cast bridges their dynamic shape to the typed context.
@@ -1568,7 +1703,9 @@ function freestandingScreenPage(screen: Screen, accessEnabled: boolean, i18nEnab
1568
1703
  return () => handlers.${ON_DESTROY}(ctx)
1569
1704
  })`
1570
1705
  : ''
1571
- const gridMarkup = grid ? ` <SvGrid data={rows} columns={columns} features={features} onApiReady={(a) => (gridApi = a)} showRowNumbers />` : ''
1706
+ // Grid events fire into ctx.grid so freestanding page code can subscribe too.
1707
+ const gridEventAttrs = grid && wiring?.usesGridHandle ? GRID_EVENTS.filter((e) => e.prop).map((e) => ` ${e.prop}={(...a) => gridCtx.fire('${e.key}', ...a)}`).join('') : ''
1708
+ const gridMarkup = grid ? ` <SvGrid data={rows} columns={columns} features={features} onApiReady={(a) => (gridApi = a)}${gridEventAttrs} showRowNumbers />` : ''
1572
1709
 
1573
1710
  const blockContent = screen.blocks.length
1574
1711
  ? screen.blocks.map((b) => (b.config.kind === 'component' ? componentBlockMarkup(b, b.config, hasCode ? handleNames.get(b.id) : undefined) : '')).filter(Boolean).join('\n')
@@ -1594,7 +1731,7 @@ ${content}
1594
1731
  /** A self-contained screen page composing the screen's blocks. When `accessEnabled`
1595
1732
  * the page gates create / update affordances by the current role (server still
1596
1733
  * enforces via the route's `authorize`). */
1597
- function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Screen, resolve: (name: string) => EntitySchema | undefined, rawResolve: (name: string) => EntitySchema | undefined, accessEnabled = false, i18nEnabled = false, routeById: Map<string, string> = new Map(), drillEnabled = false): GeneratedFile {
1734
+ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Screen, resolve: (name: string) => EntitySchema | undefined, rawResolve: (name: string) => EntitySchema | undefined, accessEnabled = false, i18nEnabled = false, routeById: Map<string, string> = new Map(), drillEnabled = false, ssrData = false, entSource?: EntityDataSource): GeneratedFile {
1598
1735
  const n = namesFor(schema)
1599
1736
  const label = schema.label ?? n.label
1600
1737
  const blocks = screen.blocks
@@ -1642,8 +1779,11 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
1642
1779
  const hasCellRenderers = cellRenderKinds.size > 0
1643
1780
  const wantsForm = !!formGrid || hasForm || hasEditAction
1644
1781
  // An unpaginated grid loads everything (one big page); else its configured size.
1645
- const gridPageSize = gridConfigs[0] ? (gridConfigs[0].paginated !== false ? gridConfigs[0].pageSize : 1000) : 10
1782
+ const gridPageSize = gridConfigs[0] ? (gridConfigs[0].paginated !== false ? (gridConfigs[0].pageSize ?? 10) : 1000) : 10
1646
1783
  const formPres = formGrid?.formPresentation ?? 'modal'
1784
+ // Optional form-layout depth (columns / field selection+order / titled sections /
1785
+ // dialog title+size) -> extra SvGridEditPanel props. Emitted only when configured.
1786
+ const formLayoutProps = editPanelLayoutProps(formGrid)
1647
1787
  const hasPivot = has(allBlocks, 'pivot')
1648
1788
  const hasFilter = has(blocks, 'filter')
1649
1789
  const hasRecord = has(blocks, 'record')
@@ -1651,27 +1791,18 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
1651
1791
  // Filter panels drive the grid's controller; record panels read the grid's
1652
1792
  // selection - both need the controller even if the grid isn't editable.
1653
1793
  const needsController = hasGrid || wantsForm || hasFilter || hasRecord
1794
+ // Supabase Realtime: when the screen's entity is Supabase-backed and opts into
1795
+ // live updates, subscribe to Postgres change streams and refresh() the paged
1796
+ // grid on any INSERT / UPDATE / DELETE (respects the active sort/filter/page).
1797
+ const rtSupabase = !ssrData && needsController && entSource?.kind === 'supabase' && entSource.realtime === true
1798
+ const rtTable = rtSupabase ? (entSource as SupabaseSource).table : ''
1654
1799
  const hasAgg = has(allBlocks, 'chart') || has(allBlocks, 'dashboard') || has(allBlocks, 'kpi') || has(allBlocks, 'gauge') || has(allBlocks, 'tree')
1655
1800
  const relationFields = schema.fields.filter((f) => f.type === 'relation' && f.relation)
1656
1801
 
1657
1802
  // Distinct, resolvable child entities referenced by master-detail blocks, and by
1658
1803
  // a detail page's related child collections (both load the child table into a
1659
1804
  // `md_<name>_rows` state var + filter it by the foreign key at render time).
1660
- const mdChildren = new Map<string, EntitySchema>()
1661
- for (const b of blocks) {
1662
- if (b.config.kind === 'master-detail' && b.config.childEntity && b.config.foreignKey) {
1663
- const c = resolve(b.config.childEntity)
1664
- if (c) mdChildren.set(c.name, c)
1665
- }
1666
- if (b.config.kind === 'detail') {
1667
- for (const rel of b.config.related ?? []) {
1668
- if (!rel.entity || !rel.foreignKey) continue
1669
- const c = resolve(rel.entity)
1670
- if (c) mdChildren.set(c.name, c)
1671
- }
1672
- }
1673
- }
1674
- const childList = [...mdChildren.values()]
1805
+ const childList = screenChildEntities(blocks, resolve)
1675
1806
  const hasMD = childList.length > 0
1676
1807
  // Components with a data binding (aggregate / field) read the whole table too.
1677
1808
  const boundComponents = allBlocks.filter((b): b is Block & { config: ComponentConfig } => b.config.kind === 'component' && componentHasBindings(b.config))
@@ -1733,8 +1864,11 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
1733
1864
  if (hasPivot) entImports.push('SvPivotDesigner')
1734
1865
  // The scheduler renderer (grid scheduler-view + calendar block) is registered app-wide (idempotent).
1735
1866
  if (usesScheduler) entImports.push('enableSchedulerView')
1867
+ if (rtSupabase) entImports.push('createSupabaseRealtime', 'type SupabaseRealtimeClientLike')
1736
1868
  // Dedupe: record + form both want SvGridEditPanel.
1737
1869
  const entImport = entImports.length ? `import { ${[...new Set(entImports)].join(', ')} } from '@svgrid/enterprise'\n ` : ''
1870
+ // Realtime pulls the shared client from the generated connections module.
1871
+ const connImport = rtSupabase ? `import { supabaseClient } from '$lib/connections'\n ` : ''
1738
1872
  const lookupVars = relationFields.map((f) => lookupVar(schema, f.field))
1739
1873
  const childSchemaVars = childList.map((c) => namesFor(c).schemaVar)
1740
1874
  const childTypes = childList.map((c) => namesFor(c).type)
@@ -1743,7 +1877,8 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
1743
1877
  // (childEntity === this entity) doesn't emit a duplicate import specifier.
1744
1878
  const schemaVarImports = [...new Set([n.schemaVar, ...childSchemaVars])]
1745
1879
  const typeImports = [...new Set([n.type, ...childTypes])]
1746
- const dataImports = [...new Set([n.sourceVar, ...childSourceVars, ...(wantsForm ? [...lookupVars, 'nextId'] : [])])].filter(Boolean)
1880
+ // SSR read pages get every dataset from the server load - no client source use.
1881
+ const dataImports = ssrData ? [] : [...new Set([n.sourceVar, ...childSourceVars, ...(wantsForm ? [...lookupVars, 'nextId'] : [])])].filter(Boolean)
1747
1882
 
1748
1883
  // Drill-through: this screen navigates out (goto) and/or is a drill target that
1749
1884
  // reads URL query params matching its fields into an initial filter.
@@ -1774,11 +1909,20 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
1774
1909
  for (const _f of [${filterableFieldNames.map(jsStr).join(', ')}]) { const _v = sp.get(_f); if (_v != null) _cols[_f] = { operator: 'equals', value: _v } }
1775
1910
  if (Object.keys(_cols).length) controller.setFilter({ columns: _cols })`
1776
1911
  : ''
1912
+ const ctlBase = `createServerDataSource<${n.type}>(${n.sourceVar}, { pageSize: ${gridPageSize}, optimistic: true, getRowId: (r) => String((r as Record<string, unknown>)[idField]), onChange: (s) => (view = s) })`
1913
+ // In code mode, wrap CRUD so page code can react to data changes via ctx.grid
1914
+ // (grid.onRowAdded / onRowUpdated / onRowDeleted). Fires only after a write settles.
1915
+ const ctlDecl = codeGrid
1916
+ ? `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) } }`
1917
+ : `const controller = ${ctlBase}`
1918
+ const rtWiring = rtSupabase
1919
+ ? `\n const __rt = createSupabaseRealtime({ client: supabaseClient as unknown as SupabaseRealtimeClientLike, table: ${jsStr(rtTable)}, debounceMs: 250, onChange: () => controller.refresh() })
1920
+ return () => { __rt.unsubscribe(); controller.dispose() } })`
1921
+ : `\n controller.refresh(); return () => controller.dispose() })`
1777
1922
  parts.push(`const idField = ${n.schemaVar}.idField ?? 'id'
1778
1923
  let view = $state<ServerState<${n.type}>>({ rows: [], total: 0, loading: false, saving: false, error: null, pageIndex: 0, pageSize: ${gridPageSize}, pageCount: 1, sortModel: [], filterModel: {} })
1779
- const controller = createServerDataSource<${n.type}>(${n.sourceVar}, { pageSize: ${gridPageSize}, optimistic: true, getRowId: (r) => String((r as Record<string, unknown>)[idField]), onChange: (s) => (view = s) })
1780
- $effect(() => {${urlFilter}
1781
- controller.refresh(); return () => controller.dispose() })`)
1924
+ ${ctlDecl}
1925
+ $effect(() => {${urlFilter}${rtSupabase ? '\n controller.refresh()' : ''}${rtWiring}`)
1782
1926
  }
1783
1927
  const actionSnippets: string[] = []
1784
1928
  // Tree-grid scripts reference `allRows`; collected here, appended AFTER its declaration.
@@ -1813,10 +1957,17 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
1813
1957
  }
1814
1958
  }
1815
1959
  if (needsAllRows) {
1816
- parts.push(`let allRows = $state<${n.type}[]>([])
1960
+ // SSR read screens get their rows from the server load (real SSR HTML);
1961
+ // the SPA path fetches client-side after mount.
1962
+ parts.push(
1963
+ ssrData
1964
+ ? `const allRows = $derived(data.rows as ${n.type}[])
1965
+ const allRowsReady = true`
1966
+ : `let allRows = $state<${n.type}[]>([])
1817
1967
  let allRowsReady = $state(false)
1818
1968
  async function loadAll() { allRows = [...(await ${n.sourceVar}.getRows({ startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} })).rows]; allRowsReady = true }
1819
- loadAll()`)
1969
+ loadAll()`,
1970
+ )
1820
1971
  }
1821
1972
  // Tree-grid state/derivations - emitted after `allRows` so it's in scope.
1822
1973
  for (const s of treeScripts) parts.push(s)
@@ -1840,6 +1991,10 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
1840
1991
  for (const c of childList) {
1841
1992
  const cn = namesFor(c)
1842
1993
  const v = mdChildVar(c.name)
1994
+ if (ssrData) {
1995
+ parts.push(`const ${v} = $derived(data.${v} as ${cn.type}[])`)
1996
+ continue
1997
+ }
1843
1998
  parts.push(`let ${v} = $state<${cn.type}[]>([])
1844
1999
  async function load_${v}() { ${v} = [...(await ${cn.sourceVar}.getRows({ startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} })).rows] }
1845
2000
  load_${v}()`)
@@ -1925,7 +2080,7 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
1925
2080
  const steps = b.config.kind === 'grid' ? screen.handlerSteps?.[rowSelectSlot(b.id)] : undefined
1926
2081
  return steps?.length ? compileHandlerSteps(steps) : undefined
1927
2082
  }
1928
- const blockCtx = (b: Block, pane: boolean) => ({ hasRecord, accessEnabled: gatesUi, routeById, i18n: i18nEnabled, rawEntity: rawSchema, rawResolve, captureApi: apiVarFor(b), handleNames: codeEnabled ? handleNames : undefined, pane, rowSelectSteps: rowSelectFor(b), ctxLiteral: codeWire?.ctxLiteral })
2083
+ const blockCtx = (b: Block, pane: boolean) => ({ hasRecord, accessEnabled: gatesUi, routeById, i18n: i18nEnabled, rawEntity: rawSchema, rawResolve, captureApi: apiVarFor(b), handleNames: codeEnabled ? handleNames : undefined, pane, rowSelectSteps: rowSelectFor(b), ctxLiteral: codeWire?.ctxLiteral, gridEventSink: codeGrid && b.id === codeGridBlockId ? 'gridCtx' : undefined })
1929
2084
  const body = blocks.map((b) => blockMarkup(schema, n.schemaVar, n.type, b, resolve, blockCtx(b, false))).filter(Boolean).join('\n')
1930
2085
  // Dock mode: each block becomes a pane rendered by id; a narrow viewport stacks the grid body.
1931
2086
  const dockPanes = isDock
@@ -1961,7 +2116,7 @@ ${canvasBody}
1961
2116
  ${body}
1962
2117
  </div>`
1963
2118
  const modal = wantsForm
1964
- ? `\n\n{#if editing !== undefined}\n <SvGridEditPanel schema={${n.schemaVar}} row={editing}${relationFields.length ? ' {lookups}' : ''} presentation="${formPres}" persistKey="${screen.route}" onSubmit={save} onCancel={() => (editing = undefined)} />\n{/if}`
2119
+ ? `\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}`
1965
2120
  : ''
1966
2121
  // currentRole is needed for either a create/update UI gate or an action's
1967
2122
  // screen-access gate; `can`/`canScreen` are pulled in only where actually used.
@@ -1973,7 +2128,7 @@ ${body}
1973
2128
  // block otherwise navigates; and the handle runtime for its data/component handles.
1974
2129
  const gotoImport = usesGoto || codeEnabled ? `import { goto } from '$app/navigation'\n ` : ''
1975
2130
  const codeImport = codeEnabled ? `import { onMount } from 'svelte'\n import * as handlers from './handlers'\n import type { PageContext } from './page-context'\n ` : ''
1976
- const handleSpecs = [codeWire?.usesHandle ? 'handle' : '', codeWire?.usesDataHandle ? 'dataHandle' : ''].filter(Boolean)
2131
+ const handleSpecs = [codeWire?.usesHandle ? 'handle' : '', codeWire?.usesDataHandle ? 'dataHandle' : '', codeWire?.usesGridHandle ? 'gridHandle' : ''].filter(Boolean)
1977
2132
  const handleImport = handleSpecs.length ? `import { ${handleSpecs.join(', ')} } from '$lib/handles.svelte'\n ` : ''
1978
2133
  const pageImport = applyUrlFilters || has(allBlocks, 'detail') || codeEnabled ? `import { page } from '$app/stores'\n ` : ''
1979
2134
  const title = i18nEnabled ? `{$t('screen.${screen.id}', ${JSON.stringify(screen.title)})}` : screen.title
@@ -1991,10 +2146,9 @@ ${body}
1991
2146
  path: `src/routes/${screen.route}/+page.svelte`,
1992
2147
  description: `${screen.title} screen (${blocks.map((b) => b.config.kind).join(', ') || 'empty'}).`,
1993
2148
  contents: `<script lang="ts">
1994
- ${gridImports}${entImport}${handleImport}${accessImport}${i18nImport}${gotoImport}${codeImport}${pageImport}import { ${schemaVarImports.join(', ')}, ${typeImports.map((t) => `type ${t}`).join(', ')} } from '$lib/schemas'
1995
- import { ${dataImports.join(', ')} } from '$lib/data'
1996
-
1997
- ${parts.join('\n\n ')}
2149
+ ${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'
2150
+ ${dataImports.length ? ` import { ${dataImports.join(', ')} } from '$lib/data'\n` : ''}
2151
+ ${ssrData ? 'let { data }: PageProps = $props()\n\n ' : ''}${parts.join('\n\n ')}
1998
2152
  </script>
1999
2153
 
2000
2154
  <h1 class="st__title">${title}</h1>
@@ -2019,6 +2173,376 @@ ${(() => {
2019
2173
  }
2020
2174
  }
2021
2175
 
2176
+ /** Distinct, resolvable child entities a screen's master-detail / detail blocks
2177
+ * reference - shared by the SPA loader, the SSR read `load`, and the page's
2178
+ * `md_<name>_rows` derivations so the three can never drift. */
2179
+ function screenChildEntities(blocks: Block[], resolve: (name: string) => EntitySchema | undefined): EntitySchema[] {
2180
+ const out = new Map<string, EntitySchema>()
2181
+ for (const b of blocks) {
2182
+ if (b.config.kind === 'master-detail' && b.config.childEntity && b.config.foreignKey) {
2183
+ const c = resolve(b.config.childEntity)
2184
+ if (c) out.set(c.name, c)
2185
+ }
2186
+ if (b.config.kind === 'detail') {
2187
+ for (const rel of b.config.related ?? []) {
2188
+ if (!rel.entity || !rel.foreignKey) continue
2189
+ const c = resolve(rel.entity)
2190
+ if (c) out.set(c.name, c)
2191
+ }
2192
+ }
2193
+ }
2194
+ return [...out.values()]
2195
+ }
2196
+
2197
+ /** `+page.server.ts` for a read-only SSR screen (data-viz / detail / master-
2198
+ * detail): a `load` that returns the full dataset (+ each child collection) -
2199
+ * the page renders real SSR HTML from `data.*`. No actions (read-only). */
2200
+ function ssrReadServerFile(schema: EntitySchema, screen: Screen, sourceKind: 'memory' | 'sql', accessEnabled: boolean, screenIds: string[], resolve: (name: string) => EntitySchema | undefined): GeneratedFile {
2201
+ const n = namesFor(schema)
2202
+ const isSql = sourceKind === 'sql'
2203
+ const children = screenChildEntities(screen.blocks, resolve)
2204
+ const rbac = accessEnabled && !isSql // sql: the /api route already enforces authz
2205
+ const srcExpr = (s: EntitySchema) =>
2206
+ isSql ? `createKitDataSource<Record<string, unknown>>({ endpoint: ${jsStr('/api/' + namesFor(s).route)}, fetch })` : namesFor(s).sourceVar
2207
+ const memImports = isSql ? [] : [...new Set([n.sourceVar, ...children.map((c) => namesFor(c).sourceVar)])]
2208
+ const childLoads = children.map((c) => ` const ${mdChildVar(c.name)} = (await ${srcExpr(c)}.getRows(PAGE)).rows`).join('\n')
2209
+ return {
2210
+ path: `src/routes/${screen.route}/+page.server.ts`,
2211
+ description: `${screen.title} - SSR load (full dataset${children.length ? ' + child collections' : ''}).`,
2212
+ contents: `import type { PageServerLoad } from './$types'
2213
+ import type { ServerRequest } from '@svgrid/grid'
2214
+ ${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` : ''}
2215
+ // The app shell is a client SPA (+layout.ts ssr=false); this screen opts back
2216
+ // INTO server rendering - real SSR HTML, not just a server-side load.
2217
+ export const ssr = true
2218
+ ${rbac ? `\nconst SCREEN_IDS = ${JSON.stringify(screenIds)}\n` : ''}
2219
+ const PAGE: ServerRequest = { startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} }
2220
+
2221
+ export const load: PageServerLoad = async (${isSql || rbac ? `{ ${[isSql ? 'fetch' : '', rbac ? 'locals' : ''].filter(Boolean).join(', ')} }` : ''}) => {
2222
+ ${rbac ? ` if (!authorizeAction(getServerRole({ locals }), 'read', SCREEN_IDS)) throw error(403, 'Not allowed')\n` : ''} const rows = (await ${srcExpr(schema)}.getRows(PAGE)).rows
2223
+ ${childLoads ? childLoads + '\n' : ''} return { rows${children.map((c) => `, ${mdChildVar(c.name)}`).join('')} }
2224
+ }
2225
+ `,
2226
+ }
2227
+ }
2228
+
2229
+ // ---------------------------------------------------------------------------
2230
+ // SSR-native output (opt-in per screen via `renderMode: 'ssr'`).
2231
+ //
2232
+ // Instead of the client data-source-controller page, an SSR screen emits idiomatic
2233
+ // SvelteKit: a `+page.server.ts` with a `load` (SSR first paint, sort/filter/page
2234
+ // read from the URL so it's shareable and works with no JS) and form `actions` for
2235
+ // create / update / delete (progressive enhancement via `use:enhance`), plus an
2236
+ // SSR `+page.svelte` that renders the server rows and drives the grid's external
2237
+ // sort/pagination back into the URL. Server-side validation runs in the actions.
2238
+ // ---------------------------------------------------------------------------
2239
+
2240
+ /** URL search params -> a data-source request. Shared by every SSR route's load. */
2241
+ const SSR_QUERY_HELPER = `// Turn a page URL's search params into a data-source request (sort / filter /
2242
+ // page / size). The grid drives these params via goto(), so the server re-runs
2243
+ // load() - which makes every list view bookmarkable and functional with no JS.
2244
+ import type { ServerRequest } from '@svgrid/grid'
2245
+
2246
+ export function planFromSearchParams(url: URL, defaultPageSize = 25): ServerRequest {
2247
+ const sp = url.searchParams
2248
+ const pageIndex = Math.max(0, Number.parseInt(sp.get('page') ?? '', 10) || 0)
2249
+ const pageSize = Math.min(200, Math.max(1, Number.parseInt(sp.get('size') ?? '', 10) || defaultPageSize))
2250
+ const sortModel = (sp.get('sort') ?? '')
2251
+ .split(',')
2252
+ .filter(Boolean)
2253
+ .map((token) => {
2254
+ const [id, dir] = token.split(':')
2255
+ return { id: id!, desc: dir === 'desc' }
2256
+ })
2257
+ const columns: Record<string, { operator: 'contains'; value: string }> = {}
2258
+ for (const [k, v] of sp) if (k.startsWith('f_') && v) columns[k.slice(2)] = { operator: 'contains', value: v }
2259
+ const global = sp.get('q') ?? ''
2260
+ return {
2261
+ startRow: pageIndex * pageSize,
2262
+ endRow: pageIndex * pageSize + pageSize,
2263
+ pageIndex,
2264
+ pageSize,
2265
+ sortModel,
2266
+ filterModel: { ...(global ? { global } : {}), columns },
2267
+ }
2268
+ }
2269
+ `
2270
+
2271
+ function ssrQueryHelperFile(): GeneratedFile {
2272
+ return { path: 'src/lib/server/query.ts', description: 'SSR: URL search params -> data-source request (sort/filter/page).', contents: SSR_QUERY_HELPER }
2273
+ }
2274
+
2275
+ const htmlEsc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
2276
+
2277
+ /** The `+page.server.ts` + SSR `+page.svelte` for a single-grid CRUD screen. */
2278
+ function emitSsrGridScreen(schema: EntitySchema, screen: Screen, sourceKind: 'memory' | 'sql', accessEnabled: boolean, screenIds: string[], byName: Map<string, EntitySchema>): GeneratedFile[] {
2279
+ const n = namesFor(schema)
2280
+ const isSql = sourceKind === 'sql'
2281
+ const relSchemaOf = (f: EntityField) => byName.get(f.relation!.entity)!
2282
+ const relIdOf = (rs: EntitySchema) => rs.idField ?? rs.fields.find((x) => x.primaryKey)?.field ?? 'id'
2283
+ const grid = screen.blocks[0]!.config as GridConfig
2284
+ const pageSize = grid.pageSize && grid.pageSize > 0 ? grid.pageSize : 25
2285
+ const wantsFilter = grid.filterable !== false
2286
+ const idField = schema.idField ?? schema.fields.find((f) => f.primaryKey)?.field ?? 'id'
2287
+ const isFormHidden = (f: EntityField) => f.hidden === true || (typeof f.hidden === 'object' && !!f.hidden.form)
2288
+ const formFields = schema.fields.filter(
2289
+ (f) => f.field !== idField && !f.primaryKey && !f.readonly && !f.computed && !f.formula && !isFormHidden(f),
2290
+ )
2291
+ const normType = (t: string): 'text' | 'number' | 'boolean' => (t === 'number' ? 'number' : t === 'boolean' ? 'boolean' : 'text')
2292
+ const fieldTypesLit = `{ ${formFields.map((f) => `${jsStr(f.field)}: ${jsStr(normType(f.type))}`).join(', ')} }`
2293
+
2294
+ // Relation fields render as a native <select> whose options are prefetched in load
2295
+ // (memory path only for now; sql relation fields stay a text FK - follow-up).
2296
+ const isRel = (f: EntityField) => f.type === 'relation' && !!f.relation && byName.has(f.relation!.entity)
2297
+ const relFields = formFields.filter(isRel)
2298
+ // The $lib/data import only needs the related sources on the memory path; on sql
2299
+ // the related options come from the related entity's /api route (via event.fetch).
2300
+ const relSourceVars = isSql ? [] : [...new Set(relFields.map((f) => namesFor(relSchemaOf(f)).sourceVar))]
2301
+ const relPrefetch = relFields
2302
+ .map((f) => {
2303
+ const rs = relSchemaOf(f)
2304
+ const rel = namesFor(rs)
2305
+ const relId = relIdOf(rs)
2306
+ const labelF = f.relation!.labelField ?? relId
2307
+ const relSource = isSql
2308
+ ? `createKitDataSource<Record<string, unknown>>({ endpoint: ${jsStr('/api/' + rel.route)}, fetch })`
2309
+ : rel.sourceVar
2310
+ return ` const ${f.field}Options = (await ${relSource}.getRows({ startRow: 0, endRow: 100, pageIndex: 0, pageSize: 100, sortModel: [], filterModel: {} })).rows.map((r: Record<string, unknown>) => ({ value: String(r[${jsStr(relId)}] ?? ''), label: String(r[${jsStr(labelF)}] ?? '') }))`
2311
+ })
2312
+ .join('\n')
2313
+ const relReturn = relFields.map((f) => `, ${f.field}Options`).join('')
2314
+
2315
+ // Source acquisition differs by kind:
2316
+ // - memory: import the in-process source from $lib/data and call it directly.
2317
+ // - sql: build a same-origin client over the connected /api/<entity> route with
2318
+ // SvelteKit's event.fetch, so validation / RBAC / triggers / audit stay enforced
2319
+ // once, in that route's createKitHandlers - not duplicated here.
2320
+ const src = isSql ? 'source(fetch)' : n.sourceVar
2321
+ const fetchArg = isSql ? ', fetch' : ''
2322
+ const enterpriseImports = isSql ? 'validateAll, createKitDataSource' : 'validateAll'
2323
+ const sourceImport = isSql ? '' : `import { ${[...new Set([n.sourceVar, ...relSourceVars]), 'nextId'].join(', ')} } from '$lib/data'\n`
2324
+ const schemaImport = `import { ${n.schemaVar}${isSql ? `, type ${n.type}` : ''} } from '$lib/schemas'`
2325
+ const idConst = isSql ? '' : `\nconst ID_FIELD = ${jsStr(idField)}`
2326
+ const srcHelper = isSql
2327
+ ? `\n// Same-origin client over the connected /api/${n.route} route (that route runs\n// validation, RBAC, triggers + audit via createKitHandlers).\nconst source = (fetch: typeof globalThis.fetch) => createKitDataSource<${n.type}>({ endpoint: ${jsStr('/api/' + n.route)}, fetch })\n`
2328
+ : ''
2329
+ const createCall = isSql
2330
+ ? `await ${src}.createRow(values)`
2331
+ : `await ${n.sourceVar}.createRow({ [ID_FIELD]: nextId(${jsStr(n.idPrefix)}), ...values })`
2332
+
2333
+ // RBAC only needs inline enforcement for the memory path; sql inherits it from the
2334
+ // connected /api route (createKitHandlers authorize), reached via event.fetch.
2335
+ const rbac = accessEnabled && !isSql
2336
+ const localsArg = rbac ? ', locals' : ''
2337
+ const readGuard = rbac ? ` if (!authorizeAction(getServerRole({ locals }), 'read', SCREEN_IDS)) throw error(403, 'Not allowed')\n` : ''
2338
+ const writeGuard = (action: string) => (rbac ? ` if (!authorizeAction(getServerRole({ locals }), '${action}', SCREEN_IDS)) return fail(403, { error: 'Not allowed' })\n` : '')
2339
+
2340
+ const server = `import type { Actions, PageServerLoad } from './$types'
2341
+ import { fail${rbac ? ', error' : ''} } from '@sveltejs/kit'
2342
+ import { ${enterpriseImports} } from '@svgrid/enterprise'
2343
+ ${rbac ? "import { authorizeAction, getServerRole } from '$lib/access'\n" : ''}${sourceImport}${schemaImport}
2344
+ import { planFromSearchParams } from '$lib/server/query'
2345
+
2346
+ // The app shell is a client SPA (+layout.ts ssr=false); this screen opts back
2347
+ // INTO server rendering - real SSR HTML, not just a server-side load.
2348
+ export const ssr = true
2349
+ ${idConst}${rbac ? `\nconst SCREEN_IDS = ${JSON.stringify(screenIds)}` : ''}
2350
+ const FIELD_TYPES: Record<string, 'text' | 'number' | 'boolean'> = ${fieldTypesLit}
2351
+ ${srcHelper}
2352
+ /** Read a submitted form into a typed partial row. Booleans come from checkbox
2353
+ * presence; numbers are coerced; empty values are dropped so they don't clobber. */
2354
+ function formToValues(fd: FormData): Record<string, unknown> {
2355
+ const values: Record<string, unknown> = {}
2356
+ for (const [field, type] of Object.entries(FIELD_TYPES)) {
2357
+ if (type === 'boolean') { values[field] = fd.get(field) != null; continue }
2358
+ const raw = fd.get(field)
2359
+ if (raw == null || raw === '') continue
2360
+ values[field] = type === 'number' ? Number(raw) : String(raw)
2361
+ }
2362
+ return values
2363
+ }
2364
+
2365
+ export const load: PageServerLoad = async ({ url${fetchArg}${localsArg} }) => {
2366
+ ${readGuard} const plan = planFromSearchParams(url, ${pageSize})
2367
+ const { rows, rowCount } = await ${src}.getRows(plan)
2368
+ ${relPrefetch ? relPrefetch + '\n' : ''} return { rows, total: rowCount, page: plan.pageIndex, size: plan.pageSize, sort: plan.sortModel${relReturn} }
2369
+ }
2370
+
2371
+ export const actions: Actions = {
2372
+ create: async ({ request${fetchArg}${localsArg} }) => {
2373
+ ${writeGuard('create')} const values = formToValues(await request.formData())
2374
+ const errors = await validateAll(${n.schemaVar}, values)
2375
+ if (Object.keys(errors).length) return fail(422, { errors, values })
2376
+ ${createCall}
2377
+ return { ok: true }
2378
+ },
2379
+ update: async ({ request${fetchArg}${localsArg} }) => {
2380
+ const fd = await request.formData()
2381
+ ${writeGuard('update')} const id = String(fd.get('__id') ?? '')
2382
+ const values = formToValues(fd)
2383
+ const errors = await validateAll(${n.schemaVar}, values)
2384
+ if (Object.keys(errors).length) return fail(422, { errors, values })
2385
+ await ${src}.updateRow(id, values)
2386
+ return { ok: true }
2387
+ },
2388
+ delete: async ({ request${fetchArg}${localsArg} }) => {
2389
+ ${writeGuard('delete')} const fd = await request.formData()
2390
+ await ${src}.deleteRow(String(fd.get('__id') ?? ''))
2391
+ return { ok: true }
2392
+ },
2393
+ }
2394
+ `
2395
+
2396
+ const row = `(editing as Record<string, unknown>)`
2397
+ const fieldBlocks = formFields
2398
+ .map((f) => {
2399
+ const key = jsStr(f.field)
2400
+ const req = f.required ? ' required' : ''
2401
+ let input: string
2402
+ if (normType(f.type) === 'boolean') {
2403
+ input = `<input type="checkbox" name=${key} checked={!isCreate && !!${row}[${key}]} />`
2404
+ } else if (f.options && f.options.length) {
2405
+ const opts = f.options
2406
+ .map((o) => `<option value=${jsStr(String(o.value))} selected={!isCreate && ${row}[${key}] === ${jsStr(String(o.value))}}>${htmlEsc(o.label ?? String(o.value))}</option>`)
2407
+ .join('')
2408
+ input = `<select name=${key}${req}>${opts}</select>`
2409
+ } else if (isRel(f)) {
2410
+ // Relation: options prefetched in load (data.<field>Options), current FK selected.
2411
+ input = `<select name=${key}${req}>\n <option value="">-</option>\n {#each data.${f.field}Options as o (o.value)}<option value={o.value} selected={!isCreate && String(${row}[${key}] ?? '') === o.value}>{o.label}</option>{/each}\n </select>`
2412
+ } else {
2413
+ const t = normType(f.type) === 'number' ? 'number' : f.type === 'date' || f.type === 'dateString' ? 'date' : 'text'
2414
+ input = `<input type="${t}" name=${key} value={isCreate ? '' : (${row}[${key}] ?? '')}${req} />`
2415
+ }
2416
+ return ` <label class="sk-field">
2417
+ <span>${htmlEsc(f.label ?? f.field)}${f.required ? ' *' : ''}</span>
2418
+ ${input}
2419
+ {#if form?.errors?.[${key}]}<em class="sk-err">{form.errors[${key}]}</em>{/if}
2420
+ </label>`
2421
+ })
2422
+ .join('\n')
2423
+
2424
+ const page = `<script lang="ts">
2425
+ import { SvGrid, renderSnippet, type ColumnDef, type CellContext } from '@svgrid/grid'
2426
+ import { schemaToColumns } from '@svgrid/enterprise'
2427
+ import { goto } from '$app/navigation'
2428
+ import { page } from '$app/state'
2429
+ import { enhance } from '$app/forms'
2430
+ import type { SubmitFunction } from '@sveltejs/kit'
2431
+ import { ${n.schemaVar}, type ${n.type} } from '$lib/schemas'
2432
+ import type { PageProps } from './$types'
2433
+
2434
+ let { data, form }: PageProps = $props()
2435
+
2436
+ const ID_FIELD = ${jsStr(idField)}
2437
+ const TITLE = ${jsStr(screen.title)}
2438
+ const NEW_LABEL = ${jsStr('New ' + n.label)}
2439
+
2440
+ // Grid columns from the schema + a row-actions column (Edit / Delete).
2441
+ const columns: ColumnDef<Record<string, never>, ${n.type}>[] = [
2442
+ ...(schemaToColumns(${n.schemaVar}) as ColumnDef<Record<string, never>, ${n.type}>[]),
2443
+ { id: '__actions', header: '', sortable: false, cell: (ctx: CellContext<${n.type}>) => renderSnippet(rowActions, { row: ctx.row.original }) },
2444
+ ]
2445
+
2446
+ // null = no editor; 'create' = new row; a row = editing that row.
2447
+ let editing = $state<'create' | ${n.type} | null>(null)
2448
+ const isCreate = $derived(editing === 'create')
2449
+
2450
+ // Sort / paginate by writing to the URL - load() re-runs on the server.
2451
+ function setParams(patch: Record<string, string | null>) {
2452
+ const sp = new URLSearchParams(page.url.searchParams)
2453
+ for (const [k, v] of Object.entries(patch)) { if (v == null) sp.delete(k); else sp.set(k, v) }
2454
+ const q = sp.toString()
2455
+ void goto(q ? \`?\${q}\` : page.url.pathname, { keepFocus: true, noScroll: true })
2456
+ }
2457
+ ${wantsFilter ? `
2458
+ // Filter via the URL (q = global search, f_<col> = per-column). The server's
2459
+ // planFromSearchParams reads these, so load() re-filters; reset to the first page.
2460
+ function applyFilters(f: { global: string; columns: Array<{ id: string; value: string }> }) {
2461
+ const sp = new URLSearchParams(page.url.searchParams)
2462
+ for (const k of [...sp.keys()]) if (k === 'q' || k.startsWith('f_')) sp.delete(k)
2463
+ if (f.global) sp.set('q', f.global)
2464
+ for (const c of f.columns) if (c.value) sp.set('f_' + c.id, c.value)
2465
+ sp.delete('page')
2466
+ const q = sp.toString()
2467
+ void goto(q ? \`?\${q}\` : page.url.pathname, { keepFocus: true, noScroll: true })
2468
+ }
2469
+ ` : ''}
2470
+ // Progressive enhancement: post to the action, keep typed values on error,
2471
+ // close the editor on success (load re-runs automatically, refreshing the grid).
2472
+ const onSubmit: SubmitFunction = () => async ({ result, update }) => {
2473
+ await update({ reset: false })
2474
+ if (result.type === 'success') editing = null
2475
+ }
2476
+ </script>
2477
+
2478
+ {#snippet rowActions({ row }: { row: ${n.type} })}
2479
+ <div class="sk-rowact">
2480
+ <button type="button" class="sk-link" onclick={() => (editing = row)}>Edit</button>
2481
+ <form method="POST" action="?/delete" use:enhance={onSubmit} style="display:contents">
2482
+ <input type="hidden" name="__id" value={(row as Record<string, unknown>)[ID_FIELD] as string} />
2483
+ <button type="submit" class="sk-link sk-danger">Delete</button>
2484
+ </form>
2485
+ </div>
2486
+ {/snippet}
2487
+
2488
+ <header class="sk-head">
2489
+ <h1>{TITLE}</h1>
2490
+ <button type="button" class="sk-btn sk-btn--primary" onclick={() => (editing = 'create')}>{NEW_LABEL}</button>
2491
+ </header>
2492
+
2493
+ <SvGrid
2494
+ data={data.rows}
2495
+ {columns}
2496
+ externalSort
2497
+ initialSorting={data.sort}
2498
+ externalPagination${wantsFilter ? '\n filterable\n externalFilter\n onFiltersChange={applyFilters}' : ''}
2499
+ rowCount={data.total}
2500
+ pageIndex={data.page}
2501
+ pageSize={data.size}
2502
+ onSortingChange={(s) => setParams({ sort: s.map((x) => \`\${x.id}:\${x.desc ? 'desc' : 'asc'}\`).join(',') || null, page: null })}
2503
+ onPaginationChange={(p) => setParams({ page: String(p.pageIndex), size: String(p.pageSize) })}
2504
+ />
2505
+
2506
+ {#if editing}
2507
+ <div class="sk-overlay">
2508
+ <form method="POST" action={isCreate ? '?/create' : '?/update'} class="sk-form" use:enhance={onSubmit}>
2509
+ <h2>{isCreate ? NEW_LABEL : 'Edit ${htmlEsc(n.label)}'}</h2>
2510
+ {#if !isCreate}<input type="hidden" name="__id" value={${row}[ID_FIELD] as string} />{/if}
2511
+ ${fieldBlocks}
2512
+ <div class="sk-form__actions">
2513
+ <button type="button" class="sk-btn" onclick={() => (editing = null)}>Cancel</button>
2514
+ <button type="submit" class="sk-btn sk-btn--primary">Save</button>
2515
+ </div>
2516
+ </form>
2517
+ </div>
2518
+ {/if}
2519
+
2520
+ <style>
2521
+ .sk-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
2522
+ .sk-head h1 { margin: 0; font-size: 20px; }
2523
+ .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; }
2524
+ .sk-btn--primary { background: var(--sg-accent, #4f46e5); border-color: var(--sg-accent, #4f46e5); color: #fff; }
2525
+ .sk-rowact { display: flex; gap: 10px; }
2526
+ .sk-link { background: none; border: none; padding: 0; font: inherit; color: var(--sg-accent, #4f46e5); cursor: pointer; }
2527
+ .sk-danger { color: #dc2626; }
2528
+ .sk-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.4); display: grid; place-items: center; z-index: 50; }
2529
+ .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); }
2530
+ .sk-form h2 { margin: 0 0 4px; font-size: 16px; }
2531
+ .sk-field { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
2532
+ .sk-field span { color: var(--sg-muted, #64748b); }
2533
+ .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); }
2534
+ .sk-field input[type='checkbox'] { align-self: flex-start; width: auto; }
2535
+ .sk-err { color: #dc2626; font-size: 12px; font-style: normal; }
2536
+ .sk-form__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 6px; }
2537
+ </style>
2538
+ `
2539
+
2540
+ return [
2541
+ { path: `src/routes/${screen.route}/+page.server.ts`, description: `SSR load + CRUD form actions for ${n.label}.`, contents: server },
2542
+ { path: `src/routes/${screen.route}/+page.svelte`, description: `${n.label} screen (SSR + progressive enhancement).`, contents: page },
2543
+ ]
2544
+ }
2545
+
2022
2546
  export function emitStudioProject(project: StudioProject): GeneratedFile[] {
2023
2547
  if (project.entities.length === 0) throw new Error('emitStudioProject: no entities to emit')
2024
2548
  if (project.screens.length === 0) throw new Error('emitStudioProject: no screens to emit')
@@ -2039,7 +2563,7 @@ export function emitStudioProject(project: StudioProject): GeneratedFile[] {
2039
2563
  if (s.entity === undefined) continue // freestanding screen - gates no entity route
2040
2564
  screensByEntity.set(s.entity, [...(screensByEntity.get(s.entity) ?? []), s.id])
2041
2565
  }
2042
- const { files, prepared } = emitEntityModules(project.entities, { sources, accessEnabled, auditEnabled, screensByEntity, triggers: project.triggers })
2566
+ const { files, prepared } = emitEntityModules(project.entities, { sources, accessEnabled, auditEnabled, screensByEntity, triggers: project.triggers, supabaseConn: project.supabase, supabaseAuth: project.auth?.enabled === true && project.auth.provider === 'supabase' })
2043
2567
  const byName = new Map(prepared.map((s) => [s.name, s]))
2044
2568
  // Raw (unprepared) entities keep their original field set - needed to derive
2045
2569
  // relation display-field names that match withRelationLabels (the prepared
@@ -2083,8 +2607,25 @@ export function emitStudioProject(project: StudioProject): GeneratedFile[] {
2083
2607
  }
2084
2608
  const schema = byName.get(screen.entity)
2085
2609
  if (!schema) throw new Error(`emitStudioProject: screen "${screen.title}" references missing entity "${screen.entity}"`)
2086
- pages.push(screenPage(schema, rawByName.get(screen.entity) ?? schema, screen, resolve, (name) => rawByName.get(name), accessEnabled, i18nEnabled, routeById, drillEnabled))
2610
+ // SSR-native path (opt-in): a grid screen gets load + form actions + URL-
2611
+ // driven sort/filter/page; a read-only screen (data-viz / detail) gets a
2612
+ // load-only server file, and the SAME page markup renders from data.*.
2613
+ if (isSsrScreen(project, screen)) {
2614
+ const srcKind = sources[screen.entity]?.kind === 'sql' ? 'sql' : 'memory'
2615
+ if (ssrScreenShape(project, screen) === 'grid') {
2616
+ pages.push(...emitSsrGridScreen(schema, screen, srcKind, accessEnabled, screensByEntity.get(screen.entity) ?? [], byName))
2617
+ } else {
2618
+ pages.push(
2619
+ ssrReadServerFile(schema, screen, srcKind, accessEnabled, screensByEntity.get(screen.entity) ?? [], resolve),
2620
+ screenPage(schema, rawByName.get(screen.entity) ?? schema, screen, resolve, (name) => rawByName.get(name), accessEnabled, i18nEnabled, routeById, drillEnabled, true),
2621
+ )
2622
+ }
2623
+ continue
2624
+ }
2625
+ pages.push(screenPage(schema, rawByName.get(screen.entity) ?? schema, screen, resolve, (name) => rawByName.get(name), accessEnabled, i18nEnabled, routeById, drillEnabled, false, sources[screen.entity]))
2087
2626
  }
2627
+ // Any SSR screen needs the shared URL-params -> query helper (server-only).
2628
+ const ssrHelpers = project.screens.some((s) => isSsrScreen(project, s)) ? [ssrQueryHelperFile()] : []
2088
2629
  const actionRouteFiles = [...actionsById.values()].map(({ action, screenId }) => actionRouteFile(action, screenId, accessEnabled))
2089
2630
 
2090
2631
  // Nav: only screens flagged into the menu, ordered, with an optional custom label.
@@ -2099,21 +2640,30 @@ export function emitStudioProject(project: StudioProject): GeneratedFile[] {
2099
2640
  // auth requires that active layer, so its user store can live in the same schema.
2100
2641
  const firstSqlSrc = sqlEntities.length ? sources[sqlEntities[0]!.name] : undefined
2101
2642
  const dataLayerActive = project.dataLayer === 'drizzle' && sqlEntities.length > 0 && dzDialect(firstSqlSrc?.kind === 'sql' ? firstSqlSrc.dialect : undefined) !== null
2102
- const dbBackedAuth = authEnabled && dataLayerActive
2643
+ // Supabase Auth: a client-side SvAuthGate over the shared Supabase client, in place
2644
+ // of the builtin cookie-session scaffold. Mutually exclusive with the builtin
2645
+ // provider, and it never uses the DB-backed user store.
2646
+ const supabaseAuth = authEnabled && project.auth?.provider === 'supabase'
2647
+ const dbBackedAuth = authEnabled && !supabaseAuth && dataLayerActive
2103
2648
  const authRegister = dbBackedAuth && project.auth?.register === true
2104
2649
  const authUserAdmin = dbBackedAuth && accessEnabled && project.auth?.userAdmin === true
2105
2650
  const authTwoFactor = dbBackedAuth && project.auth?.twoFactor === true
2106
2651
  const dataLayerList = project.dataLayer === 'drizzle' && sqlEntities.length > 0 ? dataLayerFiles(project, sqlEntities, sources, dbBackedAuth, authTwoFactor) : []
2107
- const authFileList = authEnabled ? authFiles(project, dbBackedAuth, accessEnabled) : []
2652
+ // Without the Drizzle layer (or on MSSQL, which it can't cover), ship plain SQL the
2653
+ // user runs once against their database - otherwise the tables must pre-exist.
2654
+ const ddlFiles = sqlEntities.length > 0 && !dataLayerActive ? sqlDdlFiles(project.entities, sources) : []
2655
+ // The builtin cookie-session auth files (hooks.server, /login, session store, ...)
2656
+ // are skipped for the Supabase provider - SvAuthGate replaces them.
2657
+ const authFileList = authEnabled && !supabaseAuth ? authFiles(project, dbBackedAuth, accessEnabled) : []
2108
2658
  const auditFiles = auditEnabled ? [auditModule(), auditRouteFile(), auditViewerPage()] : []
2109
2659
  // Routes that render bare (no shell) + skip the login guard.
2110
- const publicAuthRoutes = authEnabled ? ['/login', ...(authTwoFactor ? ['/login/verify'] : []), ...(authRegister ? ['/register', '/forgot-password', '/reset-password'] : [])] : []
2660
+ const publicAuthRoutes = authEnabled && !supabaseAuth ? ['/login', ...(authTwoFactor ? ['/login/verify'] : []), ...(authRegister ? ['/register', '/forgot-password', '/reset-password'] : [])] : []
2111
2661
  let navExtras = auditEnabled ? [...nav, { href: '/audit', label: 'Audit log', id: '__audit__' }] : nav
2112
2662
  // The admin Users screen is nav-gated by canScreen('__users__') - only full-access ('*') roles see it.
2113
2663
  if (authUserAdmin) navExtras = [...navExtras, { href: '/users', label: 'Users', id: '__users__' }]
2114
2664
  const i18nFiles = i18nEnabled ? [i18nModule(project)] : []
2115
2665
  const handleFiles = project.screens.some(screenHasCode) ? [handlesModuleFile()] : []
2116
- return [...files, ...accessFiles, ...authFileList, ...dataLayerList, ...auditFiles, ...i18nFiles, ...actionRouteFiles, ...pages, ...companions, ...handleFiles, layoutFile(navExtras, { accent: project.theme?.accent, shell: project.theme?.shell, title: project.title, themeVars: resolveThemeTokens(project.theme), lightVars: resolveThemeTokensFor(project.theme, 'light'), darkVars: resolveThemeTokensFor(project.theme, 'dark'), dark: isDarkTheme(project.theme), access: accessEnabled, auth: authEnabled, authRoutes: publicAuthRoutes, authAccount: dbBackedAuth, i18n: i18nEnabled, appClass: project.theme?.appClass }), homeFile(navExtras)]
2666
+ return [...files, ...accessFiles, ...authFileList, ...dataLayerList, ...ddlFiles, ...auditFiles, ...i18nFiles, ...actionRouteFiles, ...ssrHelpers, ...pages, ...companions, ...handleFiles, layoutFile(navExtras, { accent: project.theme?.accent, shell: project.theme?.shell, title: project.title, themeVars: resolveThemeTokens(project.theme), lightVars: resolveThemeTokensFor(project.theme, 'light'), darkVars: resolveThemeTokensFor(project.theme, 'dark'), dark: isDarkTheme(project.theme), access: accessEnabled, auth: authEnabled && !supabaseAuth, supabaseAuth, authRoutes: publicAuthRoutes, authAccount: dbBackedAuth, i18n: i18nEnabled, appClass: project.theme?.appClass }), homeFile(navExtras)]
2117
2667
  }
2118
2668
 
2119
2669
  /** The default-locale (`en`) message catalog, keyed for nav, screen titles, the
@@ -2590,15 +3140,24 @@ export async function setTwoFactor(email: string, on: boolean): Promise<void> {
2590
3140
  }` : ''}
2591
3141
  `
2592
3142
  : `// Regenerated by SvGrid Studio. DEMO user store - replace with your own (a DB table,
2593
- // an external identity provider, ...). Passwords here are demo seeds, like sample rows:
2594
- // change them and store hashes for production (see hashPassword / verifyPassword in ./auth).
2595
- // Tip: turn on the Drizzle data layer and this becomes a real \`auth_users\` DB table.
2596
- export type AppUser = { email: string; name: string; role: string; password: string }
3143
+ // an external identity provider, ...). The seed passwords below are demo defaults
3144
+ // (printed on the login page); they are HASHED (PBKDF2) at startup and never compared
3145
+ // in plaintext. Change them for anything real. Tip: turn on the Drizzle data layer and
3146
+ // this becomes a real \`auth_users\` DB table.
3147
+ import { hashPassword } from './auth'
3148
+
3149
+ export type AppUser = { email: string; name: string; role: string; passwordHash: string }
2597
3150
 
2598
- export const USERS: AppUser[] = [
3151
+ const SEED: Array<{ email: string; name: string; role: string; password: string }> = [
2599
3152
  ${usersLiteral},
2600
3153
  ]
2601
3154
 
3155
+ // Hash the seed passwords once at module load (top-level await) so the store holds
3156
+ // only PBKDF2 hashes - login verifies with verifyPassword(), never a plaintext compare.
3157
+ export const USERS: AppUser[] = await Promise.all(
3158
+ SEED.map(async (u) => ({ email: u.email, name: u.name, role: u.role, passwordHash: await hashPassword(u.password) })),
3159
+ )
3160
+
2602
3161
  export function findUser(email: string): AppUser | undefined {
2603
3162
  const e = email.trim().toLowerCase()
2604
3163
  return USERS.find((u) => u.email.toLowerCase() === e)
@@ -2648,9 +3207,8 @@ ${protect ? " if (!locals.user && !PUBLIC.has(url.pathname)) throw redirect(302
2648
3207
  // DB-backed store: verify against the stored PBKDF2 hash.
2649
3208
  if (!user || !(await verifyPassword(password, user.passwordHash))) return fail(401, { email, error: 'Invalid email or password.' })`
2650
3209
  : `const user = findUser(email)
2651
- // DEMO: plain comparison against the seed. For a real store keep a passwordHash and
2652
- // use \`await verifyPassword(password, user.passwordHash)\` from '$lib/server/auth'.
2653
- if (!user || user.password !== password) return fail(401, { email, error: 'Invalid email or password.' })`
3210
+ // Verify against the PBKDF2 hash of the seed (users store hashes it at startup).
3211
+ if (!user || !(await verifyPassword(password, user.passwordHash))) return fail(401, { email, error: 'Invalid email or password.' })`
2654
3212
  // 2FA (email code): after the password check, email a code + park a pending token,
2655
3213
  // then send the user to /login/verify instead of issuing the session immediately.
2656
3214
  const twoFactorBranch = twoFactor ? `
@@ -2663,7 +3221,7 @@ ${protect ? " if (!locals.user && !PUBLIC.has(url.pathname)) throw redirect(302
2663
3221
  }` : ''
2664
3222
  const loginImports = twoFactor
2665
3223
  ? "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'"
2666
- : `import { SESSION_COOKIE, SESSION_MAX_AGE, signSession${dbBacked ? ', verifyPassword' : ''} } from '$lib/server/auth'\nimport { findUser } from '$lib/server/users'`
3224
+ : `import { SESSION_COOKIE, SESSION_MAX_AGE, signSession, verifyPassword } from '$lib/server/auth'\nimport { findUser } from '$lib/server/users'`
2667
3225
  const loginServerTs = `import { fail, redirect } from '@sveltejs/kit'
2668
3226
  import type { Actions, PageServerLoad } from './$types'
2669
3227
  ${loginImports}
@@ -3512,6 +4070,60 @@ export default defineConfig({
3512
4070
  dialect: '${cfg.kit}',
3513
4071
  ${cfg.creds},
3514
4072
  })
4073
+ `
4074
+
4075
+ // db/seed.ts - inserts the sample rows into EMPTY tables after `db:migrate`, so a
4076
+ // freshly-migrated production database doesn't open onto blank grids. Lives outside
4077
+ // src/ (like drizzle.config.ts) and builds its own client from process.env, because
4078
+ // the app's client imports $env/dynamic/private which only exists under Vite.
4079
+ const { seed: seedMap } = prepareEntities(sqlEntities)
4080
+ const nameSet = new Set(sqlEntities.map((e) => e.name))
4081
+ // Parents before children so FK values resolve (simple repeated-pass ordering).
4082
+ const ordered: typeof meta = []
4083
+ const pending = [...meta]
4084
+ while (pending.length) {
4085
+ const next = pending.findIndex(({ e }) =>
4086
+ e.fields.every((f) => f.type !== 'relation' || !f.relation || !nameSet.has(f.relation.entity) || f.relation.entity === e.name || ordered.some((o) => o.e.name === f.relation!.entity)),
4087
+ )
4088
+ ordered.push(...pending.splice(next === -1 ? 0 : next, 1))
4089
+ }
4090
+ const pkNumberByName = new Map(meta.map((m) => [m.e.name, m.pkNumber]))
4091
+ const seedBlocks = ordered
4092
+ .map(({ e, tableVar, pkKey, pkNumber }) => {
4093
+ const rows = (seedMap.get(e.name) ?? []).map((row, i) => {
4094
+ const out: Record<string, unknown> = { ...row }
4095
+ if (pkNumber) out[pkKey] = i + 1
4096
+ for (const f of e.fields) {
4097
+ if (f.type === 'relation' && f.relation && pkNumberByName.get(f.relation.entity)) {
4098
+ const relCount = seedMap.get(f.relation.entity)?.length ?? 1
4099
+ out[f.field] = (i % Math.max(1, relCount)) + 1
4100
+ }
4101
+ }
4102
+ return out
4103
+ })
4104
+ if (!rows.length) return null
4105
+ return ` {
4106
+ const existing = await db.select().from(schema.${tableVar}).limit(1)
4107
+ if (existing.length === 0) {
4108
+ await db.insert(schema.${tableVar}).values(${JSON.stringify(rows)} as (typeof schema.${tableVar}.$inferInsert)[])
4109
+ console.log('seeded ${e.name} (${rows.length} rows)')
4110
+ } else console.log('${e.name} already has rows - skipped')
4111
+ }`
4112
+ })
4113
+ .filter((b): b is string => b !== null)
4114
+ const seedTs = `// Regenerated by SvGrid Studio. studio:db-seed
4115
+ // Inserts sample rows into EMPTY tables - a table that already has rows is skipped,
4116
+ // so this is safe to run repeatedly. Run AFTER migrations: npm run db:seed
4117
+ ${cfg.client}
4118
+ import * as schema from '../src/lib/server/db/schema'
4119
+
4120
+ ${cfg.setup.replaceAll('env.', 'process.env.').replace('export const db', 'const db')}
4121
+
4122
+ async function main() {
4123
+ ${seedBlocks.join('\n')}
4124
+ }
4125
+
4126
+ main().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1) })
3515
4127
  `
3516
4128
 
3517
4129
  return [
@@ -3519,6 +4131,7 @@ export default defineConfig({
3519
4131
  { path: 'src/lib/server/db/index.ts', description: 'Drizzle client.', contents: indexTs },
3520
4132
  ...repoFiles,
3521
4133
  { path: 'drizzle.config.ts', description: 'drizzle-kit config (migrations).', contents: configTs },
4134
+ ...(seedBlocks.length ? [{ path: 'db/seed.ts', description: 'Sample-row seeder (run after db:migrate: npm run db:seed).', contents: seedTs }] : []),
3522
4135
  ]
3523
4136
  }
3524
4137
 
@@ -3529,7 +4142,10 @@ const appSlug = (title: string): string =>
3529
4142
 
3530
4143
  /** The static SvelteKit + Vite scaffolding around the generated screens. */
3531
4144
  const SCAFFOLD_STATIC: ReadonlyArray<GeneratedFile> = [
3532
- { path: 'vite.config.ts', description: 'Vite config.', contents: `import { sveltekit } from '@sveltejs/kit/vite'\nimport { defineConfig } from 'vite'\n\nexport default defineConfig({ plugins: [sveltekit()] })\n` },
4145
+ // watch.ignored: `studio dev` runs this app's Vite next to the designer, which
4146
+ // auto-saves studio.config.json + a .studio/ manifest into the same folder -
4147
+ // neither should trigger a reload of the app.
4148
+ { path: 'vite.config.ts', description: 'Vite config.', contents: `import { sveltekit } from '@sveltejs/kit/vite'\nimport { defineConfig } from 'vite'\n\nexport default defineConfig({\n plugins: [sveltekit()],\n server: { watch: { ignored: ['**/.studio/**', '**/studio.config.json'] } },\n})\n` },
3533
4149
  { path: 'tsconfig.json', description: 'TypeScript config.', contents: `{\n "extends": "./.svelte-kit/tsconfig.json",\n "compilerOptions": {\n "allowJs": true,\n "checkJs": true,\n "esModuleInterop": true,\n "forceConsistentCasingInFileNames": true,\n "resolveJsonModule": true,\n "skipLibCheck": true,\n "sourceMap": true,\n "strict": true,\n "moduleResolution": "bundler"\n }\n}\n` },
3534
4150
  { path: 'src/app.html', description: 'HTML shell.', contents: `<!doctype html>\n<html lang="en">\n <head>\n <meta charset="utf-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1" />\n %sveltekit.head%\n </head>\n <body data-sveltekit-preload-data="hover">\n <div style="display: contents">%sveltekit.body%</div>\n </body>\n</html>\n` },
3535
4151
  { path: 'src/app.d.ts', description: 'SvelteKit app types.', contents: `declare global {\n namespace App {}\n}\n\nexport {}\n` },
@@ -3770,12 +4386,15 @@ export function studioDeployInfo(project: StudioProject): { label: string; cli:
3770
4386
  }
3771
4387
 
3772
4388
  /** DEPLOY.md - the runbook: required env vars, then Git-integration / CI / CLI paths. */
3773
- function deployDocs(project: StudioProject, plan: DeployPlan, envKeys: string[], hasMigrations: boolean): string {
4389
+ function deployDocs(project: StudioProject, plan: DeployPlan, envKeys: string[], hasMigrations: boolean, opts: { hasDdl?: boolean; hasSeedScript?: boolean } = {}): string {
3774
4390
  const envBlock = envKeys.length
3775
- ? `## Environment variables\n\nSet these on your host (and locally in \`.env\` - copy \`.env.example\`):\n\n${envKeys.map((k) => `- \`${k}\``).join('\n')}\n\n`
4391
+ ? `## Environment variables\n\nSet these on your host (and locally in \`.env\` - copy \`.env.example\`):\n\n${envKeys.map((k) => `- \`${k}\``).join('\n')}\n\nOptional: \`VITE_SVPRO_KEY\` (your SvGrid license key) removes the unlicensed watermark - call \`setLicenseKey(import.meta.env.VITE_SVPRO_KEY)\` once at startup (see the docs).\n\n`
3776
4392
  : ''
3777
4393
  const migrations = hasMigrations
3778
- ? `## Database migrations\n\nThis app has a typed Drizzle schema (\`src/lib/server/db/schema.ts\`). Create + apply the tables:\n\n\`\`\`bash\nnpm run db:generate # SQL migrations from the schema\nnpm run db:migrate # apply them to DATABASE_URL\n\`\`\`\n\n(Run these against your production database as part of your release step.)\n\n`
4394
+ ? `## Database migrations\n\nThis app has a typed Drizzle schema (\`src/lib/server/db/schema.ts\`). Create + apply the tables:\n\n\`\`\`bash\nnpm run db:generate # SQL migrations from the schema\nnpm run db:migrate # apply them to DATABASE_URL\n${opts.hasSeedScript ? 'npm run db:seed # optional: insert the sample rows\n' : ''}\`\`\`\n\n(Run these against your production database as part of your release step.)\n\n`
4395
+ : ''
4396
+ const ddl = opts.hasDdl
4397
+ ? `## Database tables\n\nThis app's SQL entities expect their tables to exist. Run \`db/schema.sql\` against the database your \`DATABASE_URL\` points at (psql / mysql / sqlite3 / sqlcmd, or your provider's SQL console) before first use - and optionally \`db/seed.sql\` for sample rows. Tip: enable the Drizzle data layer in Studio for real, versioned migrations instead.\n\n`
3779
4398
  : ''
3780
4399
  const gitIntegration = plan.dashboard
3781
4400
  ? `## Option A - Git integration (simplest, no secrets)\n\n1. Push this repo to GitHub.\n2. Import it at <${plan.dashboard}> - the SvelteKit build is auto-detected.\n3. Add the environment variables above in the host's dashboard.\n\nEvery push to \`main\` then redeploys automatically.\n\n`
@@ -3787,7 +4406,7 @@ function deployDocs(project: StudioProject, plan: DeployPlan, envKeys: string[],
3787
4406
  ? `## Container\n\nA \`Dockerfile\` is included:\n\n\`\`\`bash\ndocker build -t ${appSlug(project.title)} .\ndocker run -p 3000:3000 --env-file .env ${appSlug(project.title)}\n\`\`\`\n\nThe server listens on \`PORT\` (default 3000).\n\n`
3788
4407
  : ''
3789
4408
  const cli = `## Option C - Deploy from your machine\n\n\`\`\`bash\nnpm run deploy\n\`\`\`\n\n(runs \`${plan.deployScript}\`)\n`
3790
- return `# Deploying ${project.title}\n\nConfigured for **${plan.label}** (SvelteKit \`${plan.adapterModule}\`). A CI workflow (\`.github/workflows/ci.yml\`) builds + tests every push.\n\n${envBlock}${migrations}${gitIntegration}${ci}${dockerNote}${cli}`
4409
+ return `# Deploying ${project.title}\n\nConfigured for **${plan.label}** (SvelteKit \`${plan.adapterModule}\`). A CI workflow (\`.github/workflows/ci.yml\`) builds + tests every push.\n\n${envBlock}${ddl}${migrations}${gitIntegration}${ci}${dockerNote}${cli}`
3791
4410
  }
3792
4411
 
3793
4412
  /** The env-var keys the generated app reads (for DEPLOY.md). */
@@ -3795,10 +4414,37 @@ function envKeysUsed(allSource: string): string[] {
3795
4414
  const keys: string[] = []
3796
4415
  if (allSource.includes('env.DATABASE_URL')) keys.push('DATABASE_URL')
3797
4416
  if (allSource.includes('env.DATABASE_AUTH_TOKEN')) keys.push('DATABASE_AUTH_TOKEN')
4417
+ if (allSource.includes('PUBLIC_SUPABASE_URL')) keys.push('PUBLIC_SUPABASE_URL', 'PUBLIC_SUPABASE_ANON_KEY')
3798
4418
  if (allSource.includes('SESSION_SECRET')) keys.push('SESSION_SECRET')
4419
+ // Feature keys - same detection as envExample, so DEPLOY.md's checklist matches
4420
+ // .env.example instead of silently omitting the auth/email vars.
4421
+ if (allSource.includes("from '$lib/server/email'")) keys.push('EMAIL_FROM', 'RESEND_API_KEY or SMTP_HOST/PORT/USER/PASS')
4422
+ if (allSource.includes("from '$lib/server/oauth'")) {
4423
+ if (allSource.includes("'github'") || allSource.includes('"github"')) keys.push('GITHUB_CLIENT_ID', 'GITHUB_CLIENT_SECRET')
4424
+ if (allSource.includes("'google'") || allSource.includes('"google"')) keys.push('GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET')
4425
+ if (allSource.includes("'oidc'") || allSource.includes('"oidc"')) keys.push('OIDC_ISSUER', 'OIDC_CLIENT_ID', 'OIDC_CLIENT_SECRET')
4426
+ }
3799
4427
  return keys
3800
4428
  }
3801
4429
 
4430
+ /** A cryptographically-random hex secret. Portable across Node 19+ and browsers
4431
+ * (the visual designer runs codegen client-side), so every generated app gets a
4432
+ * unique real session secret instead of a shared hardcoded fallback. */
4433
+ function randomSecret(bytes = 32): string {
4434
+ const buf = new Uint8Array(bytes)
4435
+ globalThis.crypto.getRandomValues(buf)
4436
+ return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('')
4437
+ }
4438
+
4439
+ /** A ready-to-run, git-ignored `.env`: the `.env.example` template with a real
4440
+ * random `SESSION_SECRET` filled in, so the app is secure out of the box (no
4441
+ * hardcoded dev secret). Only emitted when the app actually signs sessions. */
4442
+ function envDotFile(allSource: string): string | null {
4443
+ const example = envExample(allSource)
4444
+ if (!example || !allSource.includes('SESSION_SECRET')) return null
4445
+ return example.replace('SESSION_SECRET=change-me-to-a-long-random-string', `SESSION_SECRET=${randomSecret()}`)
4446
+ }
4447
+
3802
4448
  /** A `.env.example` listing the env vars the generated code reads, when any. */
3803
4449
  function envExample(allSource: string): string | null {
3804
4450
  const lines: string[] = []
@@ -3810,6 +4456,12 @@ function envExample(allSource: string): string | null {
3810
4456
  lines.push('# Turso / libSQL database auth token.')
3811
4457
  lines.push('DATABASE_AUTH_TOKEN=')
3812
4458
  }
4459
+ if (allSource.includes('PUBLIC_SUPABASE_URL')) {
4460
+ if (lines.length) lines.push('')
4461
+ lines.push('# Supabase project (Settings > API). The anon key is public / browser-safe.')
4462
+ lines.push('PUBLIC_SUPABASE_URL=')
4463
+ lines.push('PUBLIC_SUPABASE_ANON_KEY=')
4464
+ }
3813
4465
  if (allSource.includes('SESSION_SECRET')) {
3814
4466
  if (lines.length) lines.push('')
3815
4467
  lines.push('# Session signing secret - set a long random value in production.')
@@ -3846,7 +4498,9 @@ function svelteConfig(plan: DeployPlan): string {
3846
4498
  return `import adapter from '${plan.adapterModule}'\nimport { vitePreprocess } from '@sveltejs/vite-plugin-svelte'\n\n/** @type {import('@sveltejs/kit').Config} */\nconst config = {\n preprocess: vitePreprocess(),\n kit: { adapter: adapter() },\n}\n\nexport default config\n`
3847
4499
  }
3848
4500
 
3849
- function packageJson(project: StudioProject, allSource: string): string {
4501
+ /** The runtime npm deps the generated source imports, keyed by package -> range.
4502
+ * One source of truth for package.json AND the fragment-mode dependency report. */
4503
+ export function runtimeDeps(project: StudioProject, allSource: string): Record<string, string> {
3850
4504
  const dependencies: Record<string, string> = { '@svgrid/grid': 'latest', '@svgrid/enterprise': 'latest' }
3851
4505
  if (allSource.includes("from '@supabase/supabase-js'")) dependencies['@supabase/supabase-js'] = '^2.45.0'
3852
4506
  if (allSource.includes("import pg from 'pg'")) dependencies['pg'] = '^8.11.0'
@@ -3861,17 +4515,21 @@ function packageJson(project: StudioProject, allSource: string): string {
3861
4515
  if (/from ['"]jszip['"]/.test(allSource)) dependencies['jszip'] = '^3.10.1'
3862
4516
  if (/from ['"]pdfmake(?:\/[^'"]*)?['"]/.test(allSource)) dependencies['pdfmake'] = '^0.2.10'
3863
4517
  // nodemailer is dynamically imported by the email layer only on the SMTP branch.
3864
- const usesNodemailer = /import\(['"]nodemailer['"]\)/.test(allSource)
3865
- if (usesNodemailer) dependencies['nodemailer'] = '^6.9.0'
3866
- // Typed data layer: drizzle-orm at runtime, drizzle-kit (dev) for migrations + scripts.
3867
- const drizzle = project.dataLayer === 'drizzle' && /from ['"]drizzle-orm(?:\/[^'"]*)?['"]/.test(allSource)
3868
- if (drizzle) dependencies['drizzle-orm'] = '^0.44.0'
4518
+ if (/import\(['"]nodemailer['"]\)/.test(allSource)) dependencies['nodemailer'] = '^6.9.0'
4519
+ if (project.dataLayer === 'drizzle' && /from ['"]drizzle-orm(?:\/[^'"]*)?['"]/.test(allSource)) dependencies['drizzle-orm'] = '^0.44.0'
4520
+ return dependencies
4521
+ }
4522
+
4523
+ function packageJson(project: StudioProject, allSource: string): string {
4524
+ const dependencies = runtimeDeps(project, allSource)
4525
+ const drizzle = !!dependencies['drizzle-orm']
3869
4526
  const scripts: Record<string, string> = { dev: 'vite dev', build: 'vite build', preview: 'vite preview', check: 'svelte-kit sync && svelte-check --tsconfig ./tsconfig.json', test: 'vitest run', deploy: deployPlan(project).deployScript }
3870
4527
  if (drizzle) {
3871
4528
  scripts['db:generate'] = 'drizzle-kit generate'
3872
4529
  scripts['db:migrate'] = 'drizzle-kit migrate'
3873
4530
  scripts['db:push'] = 'drizzle-kit push'
3874
4531
  scripts['db:studio'] = 'drizzle-kit studio'
4532
+ if (allSource.includes('studio:db-seed')) scripts['db:seed'] = 'tsx db/seed.ts'
3875
4533
  }
3876
4534
  const pkg = {
3877
4535
  name: appSlug(project.title),
@@ -3896,8 +4554,9 @@ function packageJson(project: StudioProject, allSource: string): string {
3896
4554
  // Driver typings so the connected route + data layer type-check cleanly.
3897
4555
  ...(dependencies['pg'] ? { '@types/pg': '^8.11.0' } : {}),
3898
4556
  ...(dependencies['better-sqlite3'] ? { '@types/better-sqlite3': '^7.6.0' } : {}),
3899
- ...(usesNodemailer ? { '@types/nodemailer': '^6.4.0' } : {}),
4557
+ ...(dependencies['nodemailer'] ? { '@types/nodemailer': '^6.4.0' } : {}),
3900
4558
  ...(drizzle ? { 'drizzle-kit': '^0.31.0' } : {}),
4559
+ ...(drizzle && allSource.includes('studio:db-seed') ? { tsx: '^4.19.0' } : {}),
3901
4560
  },
3902
4561
  }
3903
4562
  return JSON.stringify(pkg, null, 2) + '\n'
@@ -3977,6 +4636,7 @@ export function emitStudioAppBundle(project: StudioProject): GeneratedFile[] {
3977
4636
  ...plan.files,
3978
4637
  ...SCAFFOLD_STATIC,
3979
4638
  ...(envExample(allSource) ? [{ path: '.env.example', description: 'Environment variables the app reads (copy to .env and fill in).', contents: envExample(allSource)! }] : []),
4639
+ ...(envDotFile(allSource) ? [{ path: '.env', description: 'Local env (git-ignored): a real random SESSION_SECRET is pre-filled so sessions are secure out of the box; fill in the rest.', contents: envDotFile(allSource)! }] : []),
3980
4640
  { path: 'src/app.css', description: 'App theme + page styles.', contents: APP_CSS },
3981
4641
  // Your styles: a dedicated CSS file the layout imports AFTER app.css, so its rules
3982
4642
  // win. Edited in the designer's Styles panel; regenerated from the model.
@@ -3989,7 +4649,65 @@ export function emitStudioAppBundle(project: StudioProject): GeneratedFile[] {
3989
4649
  // has one, and a DEPLOY.md runbook.
3990
4650
  { path: '.github/workflows/ci.yml', description: 'CI: build + test on push / PR.', contents: CI_WORKFLOW },
3991
4651
  ...(plan.deployWorkflow ? [{ path: '.github/workflows/deploy.yml', description: `Deploy to ${plan.label} on push to main.`, contents: plan.deployWorkflow }] : []),
3992
- { path: 'DEPLOY.md', description: 'Deploy runbook (env vars, Git integration, CI, CLI).', contents: deployDocs(project, plan, envKeysUsed(allSource), generated.some((f) => f.path === 'drizzle.config.ts')) },
4652
+ { path: 'DEPLOY.md', description: 'Deploy runbook (env vars, Git integration, CI, CLI).', contents: deployDocs(project, plan, envKeysUsed(allSource), generated.some((f) => f.path === 'drizzle.config.ts'), { hasDdl: generated.some((f) => f.path === 'db/schema.sql'), hasSeedScript: generated.some((f) => f.path === 'db/seed.ts') }) },
3993
4653
  ]
3994
4654
  return [...scaffold, ...generated]
3995
4655
  }
4656
+
4657
+ /**
4658
+ * Emit ONLY the app content (`src/routes` + `src/lib` + `db`) to drop into an
4659
+ * EXISTING SvelteKit app - not a whole new app. Everything emitStudioProject
4660
+ * produces MINUS the nav shell (`+layout.svelte`) and the home redirect (home
4661
+ * `+page.svelte`), PLUS `src/app.css` (the `.st-*` page styles + `--sg-*` tokens,
4662
+ * which otherwise live only in the full bundle) and a FRAGMENT.md that lists the
4663
+ * deps to install and what the host app must provide.
4664
+ */
4665
+ export function emitStudioFragment(project: StudioProject): GeneratedFile[] {
4666
+ const generated = emitStudioProject(project)
4667
+ const allSource = generated.map((f) => f.contents).join('\n')
4668
+ // Drop the two files that assume ownership of the whole app.
4669
+ const content = generated.filter((f) => f.path !== 'src/routes/+layout.svelte' && f.path !== 'src/routes/+page.svelte')
4670
+
4671
+ const deps = runtimeDeps(project, allSource)
4672
+ const depList = Object.entries(deps).map(([name, v]) => ` ${name}@${v}`).join('\n')
4673
+ const envKeys = envKeysUsed(allSource)
4674
+ const libFiles = content.filter((f) => f.path.startsWith('src/lib/')).map((f) => `- \`${f.path}\``)
4675
+ const fragmentMd = `# Studio fragment - drop into your SvelteKit app
4676
+
4677
+ This folder holds ONLY app content - routes under \`src/routes\` and modules under
4678
+ \`src/lib\` (plus \`db/\` if present). It has no package.json, config, or app shell,
4679
+ so it drops into your existing SvelteKit app.
4680
+
4681
+ ## 1. Copy the files
4682
+ Merge \`src/routes/*\` and \`src/lib/*\` into your app's \`src/\`. \`$lib\` is a stock
4683
+ SvelteKit alias, so imports resolve with no config. Watch for filename collisions
4684
+ with your own \`src/lib\` (e.g. \`schemas.ts\`, \`data.ts\`) - rename if needed.
4685
+
4686
+ ## 2. Install dependencies
4687
+ \`\`\`bash
4688
+ npm install ${Object.keys(deps).filter((d) => d !== '@svgrid/grid' && d !== '@svgrid/enterprise').join(' ') || '@svgrid/grid @svgrid/enterprise'}
4689
+ \`\`\`
4690
+ Full list this fragment imports:
4691
+ \`\`\`
4692
+ ${depList}
4693
+ \`\`\`
4694
+
4695
+ ## 3. Styles
4696
+ Import \`src/app.css\` once (e.g. in your root \`+layout.svelte\`) - it carries the
4697
+ \`.st-*\` page styles the screens use and the \`--sg-*\` design tokens.
4698
+
4699
+ ## 4. What your app must provide
4700
+ The full-app nav shell, home redirect, auth guard, RBAC bootstrap, i18n provider,
4701
+ and theme toggle live in the bundle's \`+layout.svelte\` - NOT here. Wire nav to the
4702
+ 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')}` : ''}
4703
+
4704
+ ## Files included
4705
+ ${libFiles.join('\n')}
4706
+ `
4707
+
4708
+ return [
4709
+ ...content,
4710
+ { path: 'src/app.css', description: 'Page styles (.st-*) + design tokens - import once in your layout.', contents: APP_CSS },
4711
+ { path: 'FRAGMENT.md', description: 'How to drop this fragment into an existing SvelteKit app.', contents: fragmentMd },
4712
+ ]
4713
+ }