@svgrid/enterprise 1.2.0 → 2.0.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 (159) hide show
  1. package/dist/cdn/svgrid-enterprise.svelte-external.js +26039 -805
  2. package/dist/designer/assets/index-Dp44bTid.js +939 -0
  3. package/dist/designer/assets/index-RJp6x8tw.css +1 -0
  4. package/dist/designer/assets/jszip.min-CjMo-QGg.js +2 -0
  5. package/dist/designer/index.html +13 -0
  6. package/dist/node/studio.js +22554 -0
  7. package/package.json +10 -3
  8. package/src/SvAuthGate.svelte +115 -0
  9. package/src/SvBoard.dom.test.ts +67 -0
  10. package/src/SvBoard.svelte +192 -0
  11. package/src/SvExportMenu.svelte +553 -0
  12. package/src/SvFileInput.svelte +113 -0
  13. package/src/SvGridEditPanel.dom.test.ts +146 -0
  14. package/src/SvGridEditPanel.svelte +793 -0
  15. package/src/SvGridMasterDetail.svelte +95 -0
  16. package/src/SvImportDialog.svelte +1020 -0
  17. package/src/SvLookupInput.svelte +180 -0
  18. package/src/SvRecordDetail.dom.test.ts +137 -0
  19. package/src/SvRecordDetail.svelte +288 -0
  20. package/src/SvSchedule.dom.test.ts +57 -0
  21. package/src/SvSchedule.svelte +156 -0
  22. package/src/SvSchemaChart.svelte +233 -0
  23. package/src/SvSchemaDashboard.svelte +130 -0
  24. package/src/ai-export-pdf.test.ts +74 -0
  25. package/src/ai-export-xlsx.test.ts +87 -0
  26. package/src/ai-export.test.ts +110 -0
  27. package/src/ai.ts +1188 -782
  28. package/src/edit-panel.test.ts +213 -0
  29. package/src/edit-panel.ts +228 -0
  30. package/src/export-conditional.test.ts +60 -0
  31. package/src/export-conditional.ts +94 -0
  32. package/src/export-ooxml.test.ts +152 -0
  33. package/src/export-ooxml.ts +485 -0
  34. package/src/export-pdf.test.ts +138 -0
  35. package/src/export-pdf.ts +245 -0
  36. package/src/export-print.test.ts +66 -0
  37. package/src/export-print.ts +132 -0
  38. package/src/export-serialize.test.ts +56 -0
  39. package/src/export-serialize.ts +24 -0
  40. package/src/export-xls.ts +196 -0
  41. package/src/export-xlsx-roundtrip.test.ts +120 -0
  42. package/src/export-xlsx.test.ts +150 -0
  43. package/src/export.test.ts +349 -0
  44. package/src/export.ts +1452 -549
  45. package/src/import-automap.test.ts +131 -0
  46. package/src/import-hardening.test.ts +158 -0
  47. package/src/import.ts +1042 -648
  48. package/src/index.ts +282 -0
  49. package/src/install.ts +134 -114
  50. package/src/license-core.test.ts +23 -0
  51. package/src/license-core.ts +35 -0
  52. package/src/license.ts +103 -90
  53. package/src/master-detail.test.ts +61 -0
  54. package/src/master-detail.ts +42 -0
  55. package/src/print.ts +107 -127
  56. package/src/schema-designer.test.ts +121 -0
  57. package/src/schema-designer.ts +142 -0
  58. package/src/schema.test.ts +268 -0
  59. package/src/schema.ts +499 -0
  60. package/src/smart-shim.ts +107 -105
  61. package/src/sources/aggregate.test.ts +79 -0
  62. package/src/sources/aggregate.ts +102 -0
  63. package/src/sources/auth-supabase.test.ts +80 -0
  64. package/src/sources/auth-supabase.ts +87 -0
  65. package/src/sources/dashboard.kpi.test.ts +50 -0
  66. package/src/sources/dashboard.test.ts +61 -0
  67. package/src/sources/dashboard.ts +136 -0
  68. package/src/sources/field-inference.test.ts +60 -0
  69. package/src/sources/field-inference.ts +63 -0
  70. package/src/sources/filters.test.ts +58 -0
  71. package/src/sources/filters.ts +64 -0
  72. package/src/sources/index.ts +67 -0
  73. package/src/sources/introspect-supabase.test.ts +100 -0
  74. package/src/sources/introspect-supabase.ts +119 -0
  75. package/src/sources/realtime-supabase.test.ts +93 -0
  76. package/src/sources/realtime-supabase.ts +99 -0
  77. package/src/sources/relation-lookup.test.ts +99 -0
  78. package/src/sources/relation-lookup.ts +128 -0
  79. package/src/sources/rest-adapters.test.ts +95 -0
  80. package/src/sources/rest-adapters.ts +120 -0
  81. package/src/sources/rest.test.ts +104 -0
  82. package/src/sources/rest.ts +129 -0
  83. package/src/sources/schema-from-columns.test.ts +106 -0
  84. package/src/sources/schema-from-columns.ts +88 -0
  85. package/src/sources/supabase.test.ts +110 -0
  86. package/src/sources/supabase.ts +99 -0
  87. package/src/sources/with-entity-rules.test.ts +104 -0
  88. package/src/sources/with-entity-rules.ts +78 -0
  89. package/src/sources/with-relation-labels.test.ts +75 -0
  90. package/src/sources/with-relation-labels.ts +73 -0
  91. package/src/studio/bug-report.test.ts +101 -0
  92. package/src/studio/bug-report.ts +165 -0
  93. package/src/studio/cli.test.ts +187 -0
  94. package/src/studio/cli.ts +112 -0
  95. package/src/studio/csv.test.ts +90 -0
  96. package/src/studio/csv.ts +165 -0
  97. package/src/studio/db-connect-string.test.ts +94 -0
  98. package/src/studio/db-connect-string.ts +128 -0
  99. package/src/studio/emit-project.test.ts +923 -0
  100. package/src/studio/emit-project.ts +1273 -0
  101. package/src/studio/emit-schema.test.ts +94 -0
  102. package/src/studio/emit-schema.ts +920 -0
  103. package/src/studio/index.ts +195 -0
  104. package/src/studio/introspect-db.test.ts +186 -0
  105. package/src/studio/introspect-db.ts +312 -0
  106. package/src/studio/introspect-prisma.test.ts +99 -0
  107. package/src/studio/introspect-prisma.ts +175 -0
  108. package/src/studio/introspect.test.ts +172 -0
  109. package/src/studio/introspect.ts +310 -0
  110. package/src/studio/pipeline.test.ts +42 -0
  111. package/src/studio/project-robust.test.ts +157 -0
  112. package/src/studio/project.test.ts +473 -0
  113. package/src/studio/project.ts +916 -0
  114. package/src/studio/sample-data.test.ts +58 -0
  115. package/src/studio/sample-data.ts +200 -0
  116. package/src/studio/samples/ats.ts +202 -0
  117. package/src/studio/samples/clinic.ts +177 -0
  118. package/src/studio/samples/crm.ts +250 -0
  119. package/src/studio/samples/ecommerce.ts +187 -0
  120. package/src/studio/samples/events.ts +199 -0
  121. package/src/studio/samples/fleet.ts +184 -0
  122. package/src/studio/samples/gym.ts +213 -0
  123. package/src/studio/samples/hr.ts +193 -0
  124. package/src/studio/samples/index.ts +55 -0
  125. package/src/studio/samples/insurance.ts +195 -0
  126. package/src/studio/samples/inventory.ts +182 -0
  127. package/src/studio/samples/invoicing.ts +162 -0
  128. package/src/studio/samples/library.ts +180 -0
  129. package/src/studio/samples/live-data.test.ts +98 -0
  130. package/src/studio/samples/live-data.ts +308 -0
  131. package/src/studio/samples/projects.ts +190 -0
  132. package/src/studio/samples/realestate.ts +196 -0
  133. package/src/studio/samples/restaurant.ts +187 -0
  134. package/src/studio/samples/samples.test.ts +208 -0
  135. package/src/studio/samples/school.ts +197 -0
  136. package/src/studio/samples/seed-floor.test.ts +25 -0
  137. package/src/studio/samples/shared.ts +305 -0
  138. package/src/studio/samples/subscriptions.ts +183 -0
  139. package/src/studio/samples/support.ts +182 -0
  140. package/src/studio/scaffold-app.test.ts +91 -0
  141. package/src/studio/scaffold-app.ts +161 -0
  142. package/src/studio/scaffold.test.ts +178 -0
  143. package/src/studio/scaffold.ts +374 -0
  144. package/src/studio/themes.ts +172 -0
  145. package/src/studio/verify.test.ts +47 -0
  146. package/src/studio/verify.ts +79 -0
  147. package/src/sveltekit/in-memory.test.ts +104 -0
  148. package/src/sveltekit/in-memory.ts +129 -0
  149. package/src/sveltekit/index.ts +23 -0
  150. package/src/sveltekit/query-plan.test.ts +109 -0
  151. package/src/sveltekit/query-plan.ts +125 -0
  152. package/src/sveltekit/sql-source.test.ts +185 -0
  153. package/src/sveltekit/sql-source.ts +166 -0
  154. package/src/sveltekit/sql.test.ts +80 -0
  155. package/src/sveltekit/sql.ts +131 -0
  156. package/src/sveltekit/transport.test.ts +175 -0
  157. package/src/sveltekit/transport.ts +254 -0
  158. package/src/sveltekit/types.ts +11 -0
  159. package/src/upgrade-prompt.ts +149 -148
@@ -0,0 +1,1273 @@
1
+ /**
2
+ * emitStudioProject - turn a `StudioProject` (the visual designer's model) into
3
+ * the source files of a runnable app: `schemas.ts` + `data.ts` from the
4
+ * entities, and one self-contained `+page.svelte` per SCREEN that composes the
5
+ * screen's BLOCKS (grid, edit form, chart, dashboard, KPI) with their config,
6
+ * plus the nav shell + home.
7
+ *
8
+ * Pure + node-safe (studio subtree), reusing the create-studio emit pipeline for
9
+ * schemas/data and building each screen page directly from `@svgrid/grid` +
10
+ * `@svgrid/enterprise` (no EntityScreen dependency, so the output is
11
+ * self-contained).
12
+ */
13
+ import type { GeneratedFile } from './scaffold.js'
14
+ import type { Block, EntityDataSource, FilterPanelConfig, GridConfig, KpiConfig, PivotConfig, RecordConfig, RowAction, Screen, StudioProject } from './project.js'
15
+ import { blockColumns, entityDataSource, flattenBlocks, serializeProject } from './project.js'
16
+ import { resolveThemeTokens, isDarkTheme } from './themes.js'
17
+ import type { EntityField, EntitySchema } from '../schema.js'
18
+ import { emitEntityModules, homeFile, layoutFile, lookupVar, namesFor, relationDisplayFields, type NavItem } from './emit-schema.js'
19
+
20
+ const has = (blocks: Block[], kind: Block['config']['kind']) => blocks.some((b) => b.config.kind === kind)
21
+
22
+ /** Per-Tabs-block active-tab state var + a stable tab id. */
23
+ const tabsStateVar = (blockId: string) => `activeTab_${blockId.replace(/[^a-zA-Z0-9_$]/g, '_')}`
24
+ const tabId = (blockId: string, i: number) => `${blockId}-${i}`
25
+
26
+ /** A state var holding a master-detail block's child rows (loaded in full). */
27
+ const mdChildVar = (childName: string) => `md_${childName.replace(/[^a-zA-Z0-9]/g, '_')}_rows`
28
+
29
+ /** The visible grid columns: configured order, per-column header/width/align overrides, editability per mode. */
30
+ function gridColumnsExpr(schemaVar: string, block: Block): string {
31
+ if (block.config.kind !== 'grid') return `schemaToColumns(${schemaVar})`
32
+ const cfg = block.config
33
+ const visible = cfg.columns.filter((c) => c.show)
34
+ // Only inline editing keeps cells editable; form / read-only grids are not editable in place.
35
+ const editablePart = cfg.editing === 'inline' ? '' : ', editable: false'
36
+ if (visible.length === 0) {
37
+ return editablePart ? `schemaToColumns(${schemaVar}).map((c) => ({ ...c${editablePart} }))` : `schemaToColumns(${schemaVar})`
38
+ }
39
+ const order = visible.map((c) => `'${c.field}'`)
40
+ const ovEntries = visible
41
+ .map((c) => {
42
+ const parts: string[] = []
43
+ if (c.header) parts.push(`header: ${JSON.stringify(c.header)}`)
44
+ if (c.width != null) parts.push(`width: ${c.width}`)
45
+ if (c.align) parts.push(`align: '${c.align}'`)
46
+ return parts.length ? `'${c.field}': { ${parts.join(', ')} }` : null
47
+ })
48
+ .filter((x): x is string => !!x)
49
+ const ovDecl = ovEntries.length ? ` const ov: Record<string, Partial<(typeof all)[number]>> = { ${ovEntries.join(', ')} };` : ''
50
+ const ovPart = ovEntries.length ? ', ...(ov[String(c.field)] ?? {})' : ''
51
+ const inner = `[${order.join(', ')}].map((f) => all.find((c) => c.field === f)).filter((c): c is (typeof all)[number] => !!c)`
52
+ const mapped = editablePart || ovPart ? `${inner}.map((c) => ({ ...c${editablePart}${ovPart} }))` : inner
53
+ return `(() => { const all = schemaToColumns(${schemaVar});${ovDecl} return ${mapped} })()`
54
+ }
55
+
56
+ /** Markup for one block inside the screen grid. `ctx.hasRecord` tells a grid to
57
+ * publish its clicked row into `selectedRecord` for a sibling record panel. */
58
+ 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 } = { hasRecord: false }): string {
59
+ // A block's display label: localized via $t('block.<id>', 'literal') when i18n is on.
60
+ const tLabel = (label: string, key: string) => (ctx.i18n ? `{$t('block.${key}', ${JSON.stringify(label)})}` : label)
61
+ const span = `style="grid-column: span ${blockColumns(block)}; min-width: 0"`
62
+ const cfg = block.config
63
+ switch (cfg.kind) {
64
+ case 'grid': {
65
+ const colVar = `columns_${block.id.replace(/-/g, '_')}`
66
+ const emptyMsg = `No ${(entity.label ?? entity.name).toLowerCase()} yet.`
67
+ const lines = [`data={view.rows}`, `columns={${colVar}}`, `loading={view.loading}`, `loadingOverlay`, `emptyMessage=${JSON.stringify(emptyMsg)}`, `fitColumns`]
68
+ lines.push(`enableRowSummaries={${cfg.rowSummaries ? 'true' : 'false'}}`)
69
+ if (cfg.striped) lines.push(`zebraRows`)
70
+ if (cfg.cellSelection) lines.push(`enableCellSelection`)
71
+ if (cfg.density !== 'normal') lines.push(`rowHeight={${cfg.density === 'compact' ? 28 : 46}}`)
72
+ const leftPins = cfg.columns.filter((c) => c.show && c.pin === 'left').map((c) => `'${c.field}'`)
73
+ const rightPins = cfg.columns.filter((c) => c.show && c.pin === 'right').map((c) => `'${c.field}'`)
74
+ if (leftPins.length || rightPins.length) {
75
+ const pins = [leftPins.length ? `left: [${leftPins.join(', ')}]` : '', rightPins.length ? `right: [${rightPins.join(', ')}]` : ''].filter(Boolean).join(', ')
76
+ lines.push(`initialColumnPinning={{ ${pins} }}`, `columnVirtualization={false}`)
77
+ }
78
+ if (cfg.selectable) lines.push(`showRowSelection`)
79
+ if (cfg.sortable) lines.push(`sortable`, `externalSort`, `onSortingChange={(s) => controller.setSort(s)}`)
80
+ if (cfg.filterable) lines.push(`filterable`, `showGlobalFilter`, `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 }])) })}`)
81
+ // RBAC: gate the edit affordances on the update permission (server also enforces).
82
+ const canUpdate = ctx.accessEnabled ? `can($currentRole, 'update')` : 'true'
83
+ if (cfg.editing === 'form') lines.push(ctx.accessEnabled ? `onRowDoubleClick={(e) => { if (${canUpdate}) editing = e.row }}` : `onRowDoubleClick={(e) => (editing = e.row)}`)
84
+ if (cfg.editing === 'inline') lines.push(`onCellValueChange={(e) => { ${ctx.accessEnabled ? `if (!${canUpdate}) return; ` : ''}const row = view.rows[e.rowIndex]; if (row) controller.updateRow(String((row as Record<string, unknown>)[idField]), { [e.columnId]: e.newValue } as Partial<${typeName}>) }}`)
85
+ // Drill-through: a row click navigates to another screen, filtered by the
86
+ // clicked value. Takes precedence over a record-panel selection.
87
+ const linkRoute = cfg.rowLink && ctx.routeById?.get(cfg.rowLink.screen)
88
+ if (cfg.rowLink && linkRoute) {
89
+ const src = cfg.rowLink.sourceField ?? entity.idField ?? entity.fields.find((f) => f.primaryKey)?.field ?? 'id'
90
+ lines.push(`onRowClick={(e) => goto('/${linkRoute}?${cfg.rowLink.targetField}=' + encodeURIComponent(String((e.row as Record<string, unknown>)[${jsStr(src)}] ?? '')))}`)
91
+ } else if (ctx.hasRecord) {
92
+ lines.push(`onRowClick={(e) => (selectedRecord = e.row)}`)
93
+ }
94
+ if (cfg.paginated !== false) {
95
+ 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))}`)
96
+ if (cfg.paginationPosition && cfg.paginationPosition !== 'bottom') lines.push(`paginationPosition="${cfg.paginationPosition}"`)
97
+ const opts = cfg.pageSizeOptions
98
+ if (opts && opts.length && (opts.length !== 4 || opts.join(',') !== '10,25,50,100')) lines.push(`pageSizeOptions={[${opts.join(', ')}]}`)
99
+ }
100
+ // No-code conditional formatting -> the grid's rule engine.
101
+ const cf = conditionalFormatsExpr(cfg)
102
+ if (cf) lines.push(`conditionalFormats={${cf}}`)
103
+ lines.push(`containerHeight={${block.height ?? 360}}`)
104
+ return ` <div ${span}>
105
+ <SvGrid
106
+ ${lines.join('\n ')}
107
+ />
108
+ </div>`
109
+ }
110
+ case 'chart': {
111
+ const drillRoute = cfg.drillScreen && ctx.routeById?.get(cfg.drillScreen)
112
+ const onDrill = drillRoute ? ` onDrill={(cat) => goto('/${drillRoute}?${cfg.dimension}=' + encodeURIComponent(String(cat)))}` : ''
113
+ return ` <div ${span}>
114
+ <SvSchemaChart schema={${schemaVar}} rows={allRows} dimension="${cfg.dimension}"${cfg.measure ? ` measure="${cfg.measure}"` : ''} reduce="${cfg.reduce}" type="${cfg.type}"${block.height ? ` height={${block.height}}` : ''} controls={false} accent="var(--sg-accent)"${onDrill} />
115
+ </div>`
116
+ }
117
+ case 'dashboard':
118
+ return ` <div ${span}>
119
+ <SvSchemaDashboard schema={${schemaVar}} rows={allRows} />
120
+ </div>`
121
+ case 'kpi': {
122
+ const measurePart = cfg.measure ? `measure: '${cfg.measure}', ` : ''
123
+ const valueNum = `reduceValue(allRows, { ${measurePart}reduce: '${cfg.reduce}' })`
124
+ // Value formatting: explicit format wins, else auto ("$" for money measures).
125
+ const money = cfg.measure ? /\$/.test(entity.fields.find((f) => f.field === cfg.measure)?.label ?? '') : false
126
+ const fmt = cfg.format ?? 'auto'
127
+ const valueExpr = fmt === 'auto'
128
+ ? `${money ? "'$' + " : ''}(${valueNum}).toLocaleString(undefined, { maximumFractionDigits: 1 })`
129
+ : `formatKpiValue(${valueNum}, '${fmt}')`
130
+ const rows: string[] = [
131
+ ` <div class="kpi__head"><span class="kpi__label">${tLabel(cfg.label, block.id)}</span></div>`,
132
+ ` <strong class="kpi__value">{${valueExpr}}</strong>`,
133
+ ]
134
+ // Target: a "% of target" chip (green at/over target).
135
+ if (cfg.target != null && cfg.target !== 0) {
136
+ rows.push(` <span class="kpi__delta" class:is-up={${valueNum} >= ${cfg.target}}>{Math.round(${valueNum} / ${cfg.target} * 100)}% of target</span>`)
137
+ }
138
+ // Trend: an inline sparkline over `trendField`, with a first-to-last delta chip.
139
+ if (cfg.trendField) {
140
+ const tReduce = cfg.trendReduce ?? cfg.reduce
141
+ const seriesExpr = `kpiSeries(allRows, { trendField: '${cfg.trendField}', ${measurePart}reduce: '${tReduce}' })`
142
+ rows.push(` {#if ${seriesExpr}.length > 1}
143
+ {@const _s = ${seriesExpr}}
144
+ {@const _d = seriesDelta(_s)}
145
+ {#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}
146
+ <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>
147
+ {/if}`)
148
+ }
149
+ return ` <div ${span} class="kpi">\n${rows.join('\n')}\n </div>`
150
+ }
151
+ case 'gauge': {
152
+ const gexpr = `reduceValue(allRows, { ${cfg.measure ? `measure: '${cfg.measure}', ` : ''}reduce: '${cfg.reduce}' })`
153
+ const unit = cfg.unit ? ` unit=${JSON.stringify(cfg.unit)}` : ''
154
+ return ` <div ${span} class="gaugecard">
155
+ <span class="kpi__label">${tLabel(cfg.label, block.id)}</span>
156
+ <SvGauge value={${gexpr}} min={${cfg.min}} max={${cfg.max}}${unit} size={172} />
157
+ </div>`
158
+ }
159
+ case 'tree': {
160
+ if (!cfg.labelField || !cfg.parentField) {
161
+ return ` <div ${span}><!-- tree: set a label field + a self-referential parent field in the inspector --></div>`
162
+ }
163
+ const idExpr = `${schemaVar}.idField ?? 'id'`
164
+ return ` <div ${span} class="treecard">
165
+ <SvTree nodes={toTreeNodes(allRows as Record<string, unknown>[], ${idExpr}, ${JSON.stringify(cfg.labelField)}, ${JSON.stringify(cfg.parentField)})} />
166
+ </div>`
167
+ }
168
+ case 'tabs': {
169
+ const tabsVar = tabsStateVar(block.id)
170
+ // Localize tab labels via $t('tab.<id>', 'literal') when i18n is on (build the
171
+ // array as an expression so the labels can be function calls).
172
+ const items = ctx.i18n
173
+ ? `[${cfg.tabs.map((t, i) => `{ id: ${JSON.stringify(tabId(block.id, i))}, label: $t('tab.${tabId(block.id, i)}', ${JSON.stringify(t.label)}) }`).join(', ')}]`
174
+ : JSON.stringify(cfg.tabs.map((t, i) => ({ id: tabId(block.id, i), label: t.label })))
175
+ const panels = cfg.tabs
176
+ .map((t, i) => {
177
+ const children = t.blocks.map((cb) => blockMarkup(entity, schemaVar, typeName, cb, resolve, ctx)).filter(Boolean).join('\n')
178
+ return ` {#if id === '${tabId(block.id, i)}'}
179
+ <div class="st-screen">
180
+ ${children || ' <p style="color: var(--sg-muted, #94a3b8); font-size: 13px; padding: 10px;">This tab is empty.</p>'}
181
+ </div>
182
+ {/if}`
183
+ })
184
+ .join('\n')
185
+ return ` <div ${span}>
186
+ <SvTabs tabs={${items}} value={${tabsVar}} onChange={(id) => (${tabsVar} = id)}>
187
+ {#snippet panel(id)}
188
+ ${panels}
189
+ {/snippet}
190
+ </SvTabs>
191
+ </div>`
192
+ }
193
+ case 'master-detail': {
194
+ const child = cfg.childEntity ? resolve(cfg.childEntity) : undefined
195
+ if (!child || !cfg.foreignKey) {
196
+ return ` <div ${span}><!-- master-detail: set a child entity + foreign key in the inspector --></div>`
197
+ }
198
+ const cn = namesFor(child)
199
+ const childRows = mdChildVar(child.name)
200
+ // Optionally, a parent row drills into a detail screen (linkScreen) instead
201
+ // of expanding inline - the detail page shows the same children as a timeline.
202
+ const mdRoute = cfg.linkScreen ? ctx.routeById?.get(cfg.linkScreen) : undefined
203
+ const onParent = mdRoute ? ` onParentClick={(id) => goto('/${mdRoute}?id=' + encodeURIComponent(id))}` : ''
204
+ return ` <div ${span}>
205
+ <SvGridMasterDetail schema={${schemaVar}} data={allRows} detailSchema={${cn.schemaVar}} getChildren={(p) => ${childRows}.filter((c) => String((c as Record<string, unknown>)['${cfg.foreignKey}']) === String((p as Record<string, unknown>)[${schemaVar}.idField ?? 'id']))}${onParent}${block.height ? ` containerHeight={${block.height}}` : ''} />
206
+ </div>`
207
+ }
208
+ case 'pivot': {
209
+ const h = block.height ?? 460
210
+ return ` <div style="grid-column: span ${blockColumns(block)}; min-width: 0; height: ${h}px">
211
+ <SvPivotDesigner data={allRows} fields={${pivotFieldsExpr(entity)}} layout={${pivotLayoutExpr(cfg)}} />
212
+ </div>`
213
+ }
214
+ case 'filter':
215
+ return filterPanelMarkup(entity, block, cfg)
216
+ case 'record':
217
+ return recordPanelMarkup(entity, schemaVar, block, cfg)
218
+ case 'board': {
219
+ const h = block.height ?? 480
220
+ // A relation title/subtitle renders as the raw FK id unless pointed at the
221
+ // denormalized display field that withRelationLabels fills in (e.g. company).
222
+ // Compute against the RAW entity (ctx.rawEntity): the prepared `entity` already
223
+ // has the display columns appended, which would false-collide the naming and
224
+ // diverge from what withRelationLabels actually put on the rows.
225
+ const disp = relationDisplayFields(ctx.rawEntity ?? entity, resolve)
226
+ const asText = (field: string) => disp.get(field) ?? field
227
+ const badge = cfg.badgeField ? ` badgeField=${JSON.stringify(cfg.badgeField)}` : ''
228
+ const sub = cfg.subtitleField ? ` subtitleField=${JSON.stringify(asText(cfg.subtitleField))}` : ''
229
+ // A card click drills into a detail screen (openScreen), filtered by ?id.
230
+ const openRoute = cfg.openScreen ? ctx.routeById?.get(cfg.openScreen) : undefined
231
+ const onOpen = openRoute ? ` onOpen={(id) => goto('/${openRoute}?id=' + encodeURIComponent(id))}` : ''
232
+ // Dragging a card updates its groupBy value in the local row state (optimistic).
233
+ return ` <div style="grid-column: span ${blockColumns(block)}; min-width: 0">
234
+ <SvBoard schema={${schemaVar}} rows={allRows} loading={!allRowsReady} groupBy=${JSON.stringify(cfg.groupBy)} titleField=${JSON.stringify(asText(cfg.titleField))}${badge}${sub}${onOpen} height={${h}} onMove={(id, value) => { allRows = allRows.map((r) => String((r as Record<string, unknown>)[idField]) === String(id) ? ({ ...r, ['${cfg.groupBy}']: value }) : r) }} />
235
+ </div>`
236
+ }
237
+ case 'calendar': {
238
+ const h = block.height ?? 560
239
+ // Compute against the RAW entity (ctx.rawEntity): the prepared `entity` already
240
+ // has the display columns appended, which would false-collide the naming and
241
+ // diverge from what withRelationLabels actually put on the rows.
242
+ const disp = relationDisplayFields(ctx.rawEntity ?? entity, resolve)
243
+ const asText = (field: string) => disp.get(field) ?? field
244
+ const color = cfg.colorField ? ` colorField=${JSON.stringify(cfg.colorField)}` : ''
245
+ // An event click drills into a detail screen (openScreen), filtered by ?id.
246
+ const calRoute = cfg.openScreen ? ctx.routeById?.get(cfg.openScreen) : undefined
247
+ const onSelect = calRoute ? ` onSelect={(id) => goto('/${calRoute}?id=' + encodeURIComponent(id))}` : ''
248
+ return ` <div style="grid-column: span ${blockColumns(block)}; min-width: 0">
249
+ <SvSchedule schema={${schemaVar}} rows={allRows} loading={!allRowsReady} dateField=${JSON.stringify(cfg.dateField)} titleField=${JSON.stringify(asText(cfg.titleField))}${color}${onSelect} height={${h}} />
250
+ </div>`
251
+ }
252
+ case 'detail': {
253
+ const h = block.height
254
+ // Parent relation fields (title/subtitle/section) -> their display columns.
255
+ const disp = relationDisplayFields(ctx.rawEntity ?? entity, resolve)
256
+ const asText = (field: string) => disp.get(field) ?? field
257
+ const props: string[] = [`schema={${schemaVar}}`, `rows={allRows}`, `loading={!allRowsReady}`, `titleField=${JSON.stringify(asText(cfg.titleField))}`]
258
+ if (cfg.subtitleField) props.push(`subtitleField=${JSON.stringify(asText(cfg.subtitleField))}`)
259
+ if (cfg.statusField) props.push(`statusField=${JSON.stringify(cfg.statusField)}`)
260
+ if (cfg.metricFields?.length) props.push(`metricFields={${JSON.stringify(cfg.metricFields)}}`)
261
+ if (cfg.sections?.length) {
262
+ const secs = cfg.sections.map((s) => `{ label: ${JSON.stringify(s.label)}, fields: ${JSON.stringify(s.fields.map(asText))} }`).join(', ')
263
+ props.push(`sections={[${secs}]}`)
264
+ }
265
+ // Related child collections load into `md_<name>_rows` and filter by the FK.
266
+ const rels = (cfg.related ?? []).map((rel) => {
267
+ const child = rel.entity ? resolve(rel.entity) : undefined
268
+ if (!child || !rel.foreignKey) return null
269
+ const cn = namesFor(child)
270
+ const rawChild = ctx.rawResolve?.(rel.entity) ?? child
271
+ const cdisp = relationDisplayFields(rawChild, resolve)
272
+ const cAs = (f: string) => cdisp.get(f) ?? f
273
+ const label = rel.label ?? child.label ?? child.name
274
+ const titleF = cAs(rel.titleField ?? child.fields.find((f) => f.type === 'text' && !f.primaryKey)?.field ?? child.fields[0]?.field ?? 'id')
275
+ const parts = [`label: ${JSON.stringify(label)}`, `schema: ${cn.schemaVar}`, `rows: ${mdChildVar(child.name)}`, `foreignKey: ${JSON.stringify(rel.foreignKey)}`, `titleField: ${JSON.stringify(titleF)}`]
276
+ if (rel.parentField) parts.push(`parentField: ${JSON.stringify(rel.parentField)}`)
277
+ if (rel.subtitleField) parts.push(`subtitleField: ${JSON.stringify(cAs(rel.subtitleField))}`)
278
+ if (rel.dateField) parts.push(`dateField: ${JSON.stringify(rel.dateField)}`)
279
+ if (rel.statusField) parts.push(`statusField: ${JSON.stringify(rel.statusField)}`)
280
+ return `{ ${parts.join(', ')} }`
281
+ }).filter(Boolean)
282
+ if (rels.length) props.push(`related={[${rels.join(', ')}]}`)
283
+ // Open the record named by the URL `?id=` (set by a grid / board / calendar
284
+ // drill-through); stays switchable via the header dropdown.
285
+ props.push(`selectedId={$page.url.searchParams.get('id') ?? undefined}`)
286
+ if (h) props.push(`height={${h}}`)
287
+ return ` <div style="grid-column: span ${blockColumns(block)}; min-width: 0">
288
+ <SvRecordDetail ${props.join(' ')} />
289
+ </div>`
290
+ }
291
+ case 'lookup':
292
+ return ` <div ${span}><!-- lookup (${cfg.field}): shown in the edit form --></div>`
293
+ case 'form':
294
+ default:
295
+ return '' // the form is the edit modal, rendered after the screen grid
296
+ }
297
+ }
298
+
299
+ // --- new-block helpers ------------------------------------------------------
300
+
301
+ const jsStr = (s: string) => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
302
+
303
+ /** A JS predicate expression (over `value`) for a conditional-format rule. */
304
+ function formatPredicate(op: string, value?: string | number): string | null {
305
+ const num = Number(value)
306
+ switch (op) {
307
+ case 'eq': return `String(value) === ${JSON.stringify(String(value ?? ''))}`
308
+ case 'ne': return `String(value) !== ${JSON.stringify(String(value ?? ''))}`
309
+ case 'lt': return `Number(value) < ${num}`
310
+ case 'lte': return `Number(value) <= ${num}`
311
+ case 'gt': return `Number(value) > ${num}`
312
+ case 'gte': return `Number(value) >= ${num}`
313
+ case 'contains': return `String(value).toLowerCase().includes(${JSON.stringify(String(value ?? '').toLowerCase())})`
314
+ case 'empty': return `value == null || value === ''`
315
+ case 'notEmpty': return `value != null && value !== ''`
316
+ default: return null
317
+ }
318
+ }
319
+ /** Compile a grid's no-code format rules into a `conditionalFormats` array literal. */
320
+ function conditionalFormatsExpr(cfg: GridConfig): string | null {
321
+ const entries: string[] = []
322
+ for (const r of cfg.formatRules ?? []) {
323
+ if ((r.op === 'lt' || r.op === 'lte' || r.op === 'gt' || r.op === 'gte') && !Number.isFinite(Number(r.value))) continue
324
+ const pred = formatPredicate(r.op, r.value)
325
+ if (!pred) continue
326
+ const style = [
327
+ r.background ? `background: ${jsStr(r.background)}` : '',
328
+ r.color ? `color: ${jsStr(r.color)}` : '',
329
+ r.bold ? `fontWeight: 700` : '',
330
+ ].filter(Boolean)
331
+ if (!style.length) continue
332
+ entries.push(`{ type: 'rule' as const, columns: [${jsStr(r.field)}], when: ({ value }: { value: unknown }) => ${pred}, ${style.join(', ')} }`)
333
+ }
334
+ return entries.length ? `[${entries.join(', ')}]` : null
335
+ }
336
+ const fieldLabel = (f: EntityField) => f.label ?? f.field
337
+ /** Stable per-block identifiers for a filter panel's state + apply function. */
338
+ const facetNames = (block: Block) => {
339
+ const s = block.id.replace(/-/g, '_')
340
+ return { state: `facet_${s}`, apply: `applyFacet_${s}` }
341
+ }
342
+ const filterFieldsOf = (entity: EntitySchema, cfg: FilterPanelConfig): EntityField[] =>
343
+ cfg.fields.map((name) => entity.fields.find((f) => f.field === name)).filter((f): f is EntityField => !!f)
344
+ /** Normalise a field's enum options to `{ value, label }`. */
345
+ function enumOpts(f: EntityField): { value: string; label: string }[] {
346
+ return (f.options ?? []).map((o) =>
347
+ o && typeof o === 'object'
348
+ ? { value: String((o as { value: unknown }).value), label: String((o as { label?: unknown; value: unknown }).label ?? (o as { value: unknown }).value) }
349
+ : { value: String(o), label: String(o) },
350
+ )
351
+ }
352
+
353
+ /** Every non-key, aggregatable field as a PivotField literal (number -> measure). */
354
+ function pivotFieldsExpr(entity: EntitySchema): string {
355
+ const pk = entity.idField ?? entity.fields.find((f) => f.primaryKey)?.field
356
+ const items = entity.fields
357
+ .filter((f) => f.field !== pk && f.type !== 'json' && f.type !== 'relation')
358
+ .map((f) => {
359
+ const kind = f.type === 'number' ? 'measure' : 'dimension'
360
+ const agg = f.type === 'number' ? `, defaultAgg: 'sum'` : ''
361
+ return `{ field: ${jsStr(f.field)}, label: ${jsStr(fieldLabel(f))}, kind: '${kind}'${agg} }`
362
+ })
363
+ return `[${items.join(', ')}]`
364
+ }
365
+ /** The initial pivot layout literal from the block config. */
366
+ function pivotLayoutExpr(cfg: PivotConfig): string {
367
+ const rows = cfg.rows.map(jsStr).join(', ')
368
+ const cols = cfg.cols.map(jsStr).join(', ')
369
+ const values = cfg.measure ? `[{ field: ${jsStr(cfg.measure)}, agg: '${cfg.aggregate}' }]` : '[]'
370
+ return `{ rows: [${rows}], cols: [${cols}], values: ${values}, filters: [] }`
371
+ }
372
+
373
+ /** The `<script>` state + apply() for one filter panel. */
374
+ function filterPanelState(entity: EntitySchema, block: Block, cfg: FilterPanelConfig): string {
375
+ const { state, apply } = facetNames(block)
376
+ const assigns = filterFieldsOf(entity, cfg).map((f) => {
377
+ const key = jsStr(f.field)
378
+ if (f.type === 'boolean') return ` if (v[${key}] === 'true' || v[${key}] === 'false') c[${key}] = { operator: 'equals', value: v[${key}] === 'true' }`
379
+ if (f.type === 'enum') return ` if (v[${key}]) c[${key}] = { operator: 'equals', value: v[${key}] }`
380
+ return ` if (v[${key}]) c[${key}] = { operator: 'contains', value: v[${key}] }`
381
+ }).join('\n')
382
+ return `let ${state} = $state<Record<string, string>>({})
383
+ function ${apply}() {
384
+ const v = ${state}
385
+ const c: Record<string, { operator: 'equals' | 'contains'; value: unknown }> = {}
386
+ ${assigns}
387
+ controller.setFilter({ columns: c })
388
+ }`
389
+ }
390
+ /** The faceted filter sidebar markup, wired to its facet state. */
391
+ function filterPanelMarkup(entity: EntitySchema, block: Block, cfg: FilterPanelConfig): string {
392
+ const { state, apply } = facetNames(block)
393
+ const controls = filterFieldsOf(entity, cfg).map((f) => {
394
+ const set = `${state}[${jsStr(f.field)}] = e.currentTarget.value; ${apply}()`
395
+ if (f.type === 'enum') {
396
+ const opts = enumOpts(f).map((o) => `<option value="${o.value}">${o.label}</option>`).join('')
397
+ return ` <label class="st-filter__row"><span>${fieldLabel(f)}</span>
398
+ <select onchange={(e) => { ${set} }}><option value="">Any</option>${opts}</select>
399
+ </label>`
400
+ }
401
+ if (f.type === 'boolean') {
402
+ return ` <label class="st-filter__row"><span>${fieldLabel(f)}</span>
403
+ <select onchange={(e) => { ${set} }}><option value="">Any</option><option value="true">Yes</option><option value="false">No</option></select>
404
+ </label>`
405
+ }
406
+ return ` <label class="st-filter__row"><span>${fieldLabel(f)}</span>
407
+ <input type="search" placeholder="Search…" oninput={(e) => { ${set} }} />
408
+ </label>`
409
+ }).join('\n')
410
+ return ` <aside style="grid-column: span ${blockColumns(block)}; min-width: 0" class="st-filter">
411
+ <div class="st-filter__title">${cfg.title ?? 'Filters'}</div>
412
+ ${controls}
413
+ </aside>`
414
+ }
415
+ /** A `{#snippet}` rendering a grid row's action buttons (edit / delete / navigate).
416
+ * Buttons are unrolled statically; RBAC gates edit/delete when `gate` is set. */
417
+ function rowActionsSnippet(idSafe: string, typeName: string, entity: EntitySchema, actions: RowAction[], routeById: Map<string, string>, gate: boolean): string {
418
+ const idField = entity.idField ?? entity.fields.find((f) => f.primaryKey)?.field ?? 'id'
419
+ const rowId = `String((row as Record<string, unknown>)[${jsStr(idField)}] ?? '')`
420
+ const buttons = actions.map((a) => {
421
+ if (a.kind === 'edit') {
422
+ const btn = `<button type="button" class="st-rowaction" onclick={(e) => { e.stopPropagation(); editing = row }}>${a.label ?? 'Edit'}</button>`
423
+ return gate ? `{#if can($currentRole, 'update')}${btn}{/if}` : btn
424
+ }
425
+ if (a.kind === 'delete') {
426
+ const btn = `<button type="button" class="st-rowaction st-rowaction--danger" onclick={(e) => { e.stopPropagation(); controller.deleteRow(${rowId}) }}>${a.label ?? 'Delete'}</button>`
427
+ return gate ? `{#if can($currentRole, 'delete')}${btn}{/if}` : btn
428
+ }
429
+ const route = a.screen && routeById.get(a.screen)
430
+ if (!route) return ''
431
+ const src = a.sourceField ?? idField
432
+ const target = a.targetField ?? idField
433
+ return `<button type="button" class="st-rowaction" onclick={(e) => { e.stopPropagation(); goto('/${route}?${target}=' + encodeURIComponent(String((row as Record<string, unknown>)[${jsStr(src)}] ?? ''))) }}>${a.label ?? 'Open'}</button>`
434
+ }).filter(Boolean).join('\n ')
435
+ return `{#snippet rowActions_${idSafe}({ row }: { row: ${typeName} })}
436
+ <div class="st-rowactions">
437
+ ${buttons}
438
+ </div>
439
+ {/snippet}`
440
+ }
441
+
442
+ /** The record detail panel markup: an inline edit form (editable) or a read-only
443
+ * field list, bound to `selectedRecord`. */
444
+ function recordPanelMarkup(entity: EntitySchema, schemaVar: string, block: Block, cfg: RecordConfig): string {
445
+ const span = `style="grid-column: span ${blockColumns(block)}; min-width: 0"`
446
+ let inner: string
447
+ if (cfg.editable) {
448
+ const pres = cfg.presentation ?? 'inline'
449
+ // Modal / drawer float over the page (shown only while a row is selected);
450
+ // inline lives in the block, with a prompt when nothing is selected.
451
+ inner = pres === 'inline'
452
+ ? ` {#if selectedRecord}
453
+ <SvGridEditPanel schema={${schemaVar}} row={selectedRecord} presentation="inline" onSubmit={saveRecord} onCancel={() => (selectedRecord = null)} />
454
+ {:else}
455
+ <p class="st-hint">Select a row to see its details.</p>
456
+ {/if}`
457
+ : ` <p class="st-hint">Select a row to open its ${pres === 'drawer' ? 'editor drawer' : 'edit dialog'}.</p>
458
+ {#if selectedRecord}
459
+ <SvGridEditPanel schema={${schemaVar}} row={selectedRecord} presentation="${pres}" onSubmit={saveRecord} onCancel={() => (selectedRecord = null)} />
460
+ {/if}`
461
+ } else {
462
+ const pk = entity.idField ?? entity.fields.find((f) => f.primaryKey)?.field
463
+ const chosen = cfg.fields && cfg.fields.length
464
+ ? entity.fields.filter((f) => cfg.fields!.includes(f.field))
465
+ : entity.fields.filter((f) => f.field !== pk)
466
+ const rows = chosen.map((f) => ` <div class="st-record__row"><dt>${fieldLabel(f)}</dt><dd>{String((selectedRecord as Record<string, unknown>)[${jsStr(f.field)}] ?? '')}</dd></div>`).join('\n')
467
+ inner = ` {#if selectedRecord}
468
+ <dl class="st-record">
469
+ ${rows}
470
+ </dl>
471
+ {:else}
472
+ <p class="st-hint">Select a row to see its details.</p>
473
+ {/if}`
474
+ }
475
+ return ` <div ${span} class="st-record-card">
476
+ ${inner}
477
+ </div>`
478
+ }
479
+
480
+ /** A self-contained screen page composing the screen's blocks. When `accessEnabled`
481
+ * the page gates create / update affordances by the current role (server still
482
+ * enforces via the route's `authorize`). */
483
+ 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 {
484
+ const n = namesFor(schema)
485
+ const label = schema.label ?? n.label
486
+ const blocks = screen.blocks
487
+ // Display blocks (chart/kpi/gauge/pivot/tree/dashboard) can be nested inside Tabs;
488
+ // flatten so their imports + data loading are detected. Controller-bound kinds
489
+ // (grid/form/filter/record) only live at the top level.
490
+ const allBlocks = flattenBlocks(blocks)
491
+ const hasGrid = has(blocks, 'grid')
492
+ const hasForm = has(blocks, 'form') // legacy standalone form block
493
+ // Editing is a Grid property: a grid with editing 'form' opens the edit panel.
494
+ const gridConfigs = blocks.map((b) => b.config).filter((c): c is GridConfig => c.kind === 'grid')
495
+ const formGrid = gridConfigs.find((c) => c.editing === 'form')
496
+ // An "Edit" row-action needs the edit modal + state even on a non-form grid.
497
+ const hasEditAction = gridConfigs.some((c) => c.rowActions?.some((a) => a.kind === 'edit'))
498
+ const hasRowActions = gridConfigs.some((c) => (c.rowActions?.length ?? 0) > 0)
499
+ const wantsForm = !!formGrid || hasForm || hasEditAction
500
+ // An unpaginated grid loads everything (one big page); else its configured size.
501
+ const gridPageSize = gridConfigs[0] ? (gridConfigs[0].paginated !== false ? gridConfigs[0].pageSize : 1000) : 10
502
+ const formPres = formGrid?.formPresentation ?? 'modal'
503
+ const hasPivot = has(allBlocks, 'pivot')
504
+ const hasFilter = has(blocks, 'filter')
505
+ const hasRecord = has(blocks, 'record')
506
+ const recordEditable = blocks.some((b) => b.config.kind === 'record' && b.config.editable)
507
+ // Filter panels drive the grid's controller; record panels read the grid's
508
+ // selection - both need the controller even if the grid isn't editable.
509
+ const needsController = hasGrid || wantsForm || hasFilter || hasRecord
510
+ const hasAgg = has(allBlocks, 'chart') || has(allBlocks, 'dashboard') || has(allBlocks, 'kpi') || has(allBlocks, 'gauge') || has(allBlocks, 'tree')
511
+ const relationFields = schema.fields.filter((f) => f.type === 'relation' && f.relation)
512
+
513
+ // Distinct, resolvable child entities referenced by master-detail blocks, and by
514
+ // a detail page's related child collections (both load the child table into a
515
+ // `md_<name>_rows` state var + filter it by the foreign key at render time).
516
+ const mdChildren = new Map<string, EntitySchema>()
517
+ for (const b of blocks) {
518
+ if (b.config.kind === 'master-detail' && b.config.childEntity && b.config.foreignKey) {
519
+ const c = resolve(b.config.childEntity)
520
+ if (c) mdChildren.set(c.name, c)
521
+ }
522
+ if (b.config.kind === 'detail') {
523
+ for (const rel of b.config.related ?? []) {
524
+ if (!rel.entity || !rel.foreignKey) continue
525
+ const c = resolve(rel.entity)
526
+ if (c) mdChildren.set(c.name, c)
527
+ }
528
+ }
529
+ }
530
+ const childList = [...mdChildren.values()]
531
+ const hasMD = childList.length > 0
532
+ // The pivot reads the whole table (like charts / dashboards).
533
+ const needsAllRows = hasAgg || hasMD || hasPivot || has(allBlocks, 'board') || has(allBlocks, 'calendar') || has(allBlocks, 'detail')
534
+
535
+ // --- imports ---
536
+ const gridSpecs: string[] = []
537
+ if (needsController) gridSpecs.push('SvGrid', 'createServerDataSource', ...(hasRowActions ? ['renderSnippet'] : []), 'type ServerState')
538
+ if (has(allBlocks, 'gauge')) gridSpecs.push('SvGauge')
539
+ if (has(allBlocks, 'tree')) gridSpecs.push('SvTree')
540
+ if (has(blocks, 'tabs')) gridSpecs.push('SvTabs')
541
+ const gridImports = gridSpecs.length ? `import { ${gridSpecs.join(', ')} } from '@svgrid/grid'\n ` : ''
542
+ const entImports: string[] = []
543
+ if (hasGrid) entImports.push('schemaToColumns')
544
+ if (wantsForm || (hasRecord && recordEditable)) entImports.push('SvGridEditPanel')
545
+ if (has(allBlocks, 'chart')) entImports.push('SvSchemaChart')
546
+ if (has(allBlocks, 'dashboard')) entImports.push('SvSchemaDashboard')
547
+ if (has(allBlocks, 'board')) entImports.push('SvBoard')
548
+ if (has(allBlocks, 'calendar')) entImports.push('SvSchedule')
549
+ if (has(allBlocks, 'detail')) entImports.push('SvRecordDetail')
550
+ if (has(allBlocks, 'kpi') || has(allBlocks, 'gauge')) entImports.push('reduceValue')
551
+ const kpiCfgs = allBlocks.filter((b) => b.config.kind === 'kpi').map((b) => b.config as KpiConfig)
552
+ if (kpiCfgs.some((c) => c.format && c.format !== 'auto')) entImports.push('formatKpiValue')
553
+ if (kpiCfgs.some((c) => c.trendField)) entImports.push('kpiSeries', 'sparklinePoints', 'seriesDelta')
554
+ if (hasMD) entImports.push('SvGridMasterDetail')
555
+ if (hasPivot) entImports.push('SvPivotDesigner')
556
+ // Dedupe: record + form both want SvGridEditPanel.
557
+ const entImport = entImports.length ? `import { ${[...new Set(entImports)].join(', ')} } from '@svgrid/enterprise'\n ` : ''
558
+ const lookupVars = relationFields.map((f) => lookupVar(schema, f.field))
559
+ const childSchemaVars = childList.map((c) => namesFor(c).schemaVar)
560
+ const childTypes = childList.map((c) => namesFor(c).type)
561
+ const childSourceVars = childList.map((c) => namesFor(c).sourceVar)
562
+ // Dedupe against the parent's own names so a self-referential master-detail
563
+ // (childEntity === this entity) doesn't emit a duplicate import specifier.
564
+ const schemaVarImports = [...new Set([n.schemaVar, ...childSchemaVars])]
565
+ const typeImports = [...new Set([n.type, ...childTypes])]
566
+ const dataImports = [...new Set([n.sourceVar, ...childSourceVars, ...(wantsForm ? [...lookupVars, 'nextId'] : [])])].filter(Boolean)
567
+
568
+ // Drill-through: this screen navigates out (goto) and/or is a drill target that
569
+ // reads URL query params matching its fields into an initial filter.
570
+ const usesGoto = blocks.some((b) =>
571
+ (b.config.kind === 'grid' && b.config.rowLink && routeById.has(b.config.rowLink.screen)) ||
572
+ (b.config.kind === 'grid' && b.config.rowActions?.some((a) => a.kind === 'navigate' && a.screen && routeById.has(a.screen))) ||
573
+ (b.config.kind === 'chart' && b.config.drillScreen && routeById.has(b.config.drillScreen)) ||
574
+ ((b.config.kind === 'board' || b.config.kind === 'calendar') && b.config.openScreen != null && routeById.has(b.config.openScreen)) ||
575
+ (b.config.kind === 'master-detail' && b.config.linkScreen != null && routeById.has(b.config.linkScreen)))
576
+ const applyUrlFilters = drillEnabled && needsController
577
+ const filterableFieldNames = schema.fields.filter((f) => !f.primaryKey).map((f) => f.field)
578
+ // RBAC gates the UI only where there's a create/update affordance to gate.
579
+ const gatesUi = accessEnabled && (wantsForm || gridConfigs.some((c) => c.editing === 'inline') || hasRowActions)
580
+
581
+ // --- script body ---
582
+ const parts: string[] = []
583
+ if (needsController) {
584
+ const urlFilter = applyUrlFilters
585
+ ? `\n const sp = $page.url.searchParams
586
+ const _cols: Record<string, { operator: 'equals'; value: string }> = {}
587
+ for (const _f of [${filterableFieldNames.map(jsStr).join(', ')}]) { const _v = sp.get(_f); if (_v != null) _cols[_f] = { operator: 'equals', value: _v } }
588
+ if (Object.keys(_cols).length) controller.setFilter({ columns: _cols })`
589
+ : ''
590
+ parts.push(`const idField = ${n.schemaVar}.idField ?? 'id'
591
+ let view = $state<ServerState<${n.type}>>({ rows: [], total: 0, loading: false, saving: false, error: null, pageIndex: 0, pageSize: ${gridPageSize}, pageCount: 1, sortModel: [], filterModel: {} })
592
+ const controller = createServerDataSource<${n.type}>(${n.sourceVar}, { pageSize: ${gridPageSize}, optimistic: true, getRowId: (r) => String((r as Record<string, unknown>)[idField]), onChange: (s) => (view = s) })
593
+ $effect(() => {${urlFilter}
594
+ controller.refresh(); return () => controller.dispose() })`)
595
+ }
596
+ const actionSnippets: string[] = []
597
+ for (const b of blocks) {
598
+ if (b.config.kind === 'grid') {
599
+ const idSafe = b.id.replace(/-/g, '_')
600
+ const colExpr = gridColumnsExpr(n.schemaVar, b)
601
+ let colValue = i18nEnabled ? `localizeCols(${colExpr}, ${JSON.stringify(schema.name)}, $t)` : colExpr
602
+ // An "edit" action needs the form; drop it if this grid has no edit modal.
603
+ const actions = (b.config.rowActions ?? []).filter((a) => a.kind !== 'edit' || wantsForm)
604
+ if (actions.length) {
605
+ // A synthetic action column (id, no field, cell renderer) - type-clean.
606
+ colValue = `[...(${colValue}), { id: '__actions', header: 'Actions', sortable: false, cell: (ctx) => renderSnippet(rowActions_${idSafe}, { row: ctx.row.original }) }]`
607
+ actionSnippets.push(rowActionsSnippet(idSafe, n.type, schema, actions, routeById, gatesUi))
608
+ }
609
+ const reactive = i18nEnabled || actions.length > 0
610
+ parts.push(`const columns_${idSafe} = ${reactive ? `$derived(${colValue})` : colValue}`)
611
+ }
612
+ }
613
+ if (needsAllRows) {
614
+ parts.push(`let allRows = $state<${n.type}[]>([])
615
+ let allRowsReady = $state(false)
616
+ async function loadAll() { allRows = [...(await ${n.sourceVar}.getRows({ startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} })).rows]; allRowsReady = true }
617
+ loadAll()`)
618
+ }
619
+ for (const c of childList) {
620
+ const cn = namesFor(c)
621
+ const v = mdChildVar(c.name)
622
+ parts.push(`let ${v} = $state<${cn.type}[]>([])
623
+ async function load_${v}() { ${v} = [...(await ${cn.sourceVar}.getRows({ startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} })).rows] }
624
+ load_${v}()`)
625
+ }
626
+ if (wantsForm) {
627
+ const lookupsProp = relationFields.length
628
+ ? `\n const lookups = { ${relationFields.map((f, i) => `${f.field}: ${lookupVars[i]}`).join(', ')} }`
629
+ : ''
630
+ parts.push(`let editing = $state<${n.type} | null | undefined>(undefined)${lookupsProp}
631
+ async function save({ mode, id, values }: { mode: 'create' | 'edit'; id: string | null; values: Partial<${n.type}> }) {
632
+ if (mode === 'create') { await controller.createRow({ [idField]: nextId('${n.idPrefix}'), ...values } as Partial<${n.type}>); controller.setPage(view.pageCount - 1) }
633
+ else if (id) { await controller.updateRow(id, values) }
634
+ editing = undefined${needsAllRows ? '\n await loadAll()' : ''}
635
+ }`)
636
+ }
637
+ // Record panel: the row selected in the grid, plus (when editable) a save hook.
638
+ if (hasRecord) {
639
+ parts.push(`let selectedRecord = $state<${n.type} | null>(null)${recordEditable ? `
640
+ async function saveRecord({ id, values }: { mode: 'create' | 'edit'; id: string | null; values: Partial<${n.type}> }) {
641
+ if (id) { await controller.updateRow(id, values) }
642
+ selectedRecord = null${needsAllRows ? '\n await loadAll()' : ''}
643
+ }` : ''}`)
644
+ }
645
+ // Filter panel(s): one facet-state object + an apply() that rebuilds the whole
646
+ // filterModel (setFilter replaces it) and pushes it to the controller.
647
+ for (const b of blocks) {
648
+ if (b.config.kind !== 'filter') continue
649
+ parts.push(filterPanelState(schema, b, b.config))
650
+ }
651
+ // Tabs container(s): one active-tab state var per block (first tab active).
652
+ for (const b of blocks) {
653
+ if (b.config.kind !== 'tabs') continue
654
+ parts.push(`let ${tabsStateVar(b.id)} = $state('${tabId(b.id, 0)}')`)
655
+ }
656
+ // Tree block: fold the flat rows into SvTree nodes by a self-referential parent.
657
+ if (has(allBlocks, 'tree')) {
658
+ parts.push(`type TreeNode = { id: string; label: string; children: TreeNode[] }
659
+ function toTreeNodes(rows: Record<string, unknown>[], idField: string, labelField: string, parentField: string): TreeNode[] {
660
+ const byId = new Map<string, TreeNode>(rows.map((r) => [String(r[idField]), { id: String(r[idField]), label: String(r[labelField] ?? r[idField]), children: [] }]))
661
+ const roots: TreeNode[] = []
662
+ for (const r of rows) {
663
+ const node = byId.get(String(r[idField]))!
664
+ const pid = r[parentField] != null && r[parentField] !== '' ? String(r[parentField]) : null
665
+ if (pid && pid !== node.id && byId.has(pid)) byId.get(pid)!.children.push(node)
666
+ else roots.push(node)
667
+ }
668
+ return roots
669
+ }`)
670
+ }
671
+
672
+ // --- markup ---
673
+ const newLabel = i18nEnabled ? `{$t('new.${schema.name}', ${JSON.stringify('+ New ' + label)})}` : `+ New ${label}`
674
+ const newBtn = `<button class="st-btn st-btn--primary" onclick={() => (editing = null)}>${newLabel}</button>`
675
+ const toolbar = wantsForm
676
+ ? `<div class="st__toolbar">\n ${gatesUi ? `{#if can($currentRole, 'create')}${newBtn}{/if}` : newBtn}\n</div>\n\n`
677
+ : ''
678
+ const body = blocks.map((b) => blockMarkup(schema, n.schemaVar, n.type, b, resolve, { hasRecord, accessEnabled: gatesUi, routeById, i18n: i18nEnabled, rawEntity: rawSchema, rawResolve })).filter(Boolean).join('\n')
679
+ const modal = wantsForm
680
+ ? `\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}`
681
+ : ''
682
+ const accessImport = gatesUi ? `import { currentRole, can } from '$lib/access'\n ` : ''
683
+ const i18nImport = i18nEnabled ? `import { t, localizeCols } from '$lib/i18n'\n ` : ''
684
+ const gotoImport = usesGoto ? `import { goto } from '$app/navigation'\n ` : ''
685
+ const pageImport = applyUrlFilters || has(allBlocks, 'detail') ? `import { page } from '$app/stores'\n ` : ''
686
+ const title = i18nEnabled ? `{$t('screen.${screen.id}', ${JSON.stringify(screen.title)})}` : screen.title
687
+ // Surface a failed data load (silent empty grid otherwise) with a retry.
688
+ const errorBanner = needsController
689
+ ? `\n{#if view.error}
690
+ <div class="st-error" role="alert">
691
+ <span>Couldn't load data. {view.error instanceof Error ? view.error.message : String(view.error)}</span>
692
+ <button type="button" class="st-btn" onclick={() => controller.refresh()}>Retry</button>
693
+ </div>
694
+ {/if}\n`
695
+ : ''
696
+
697
+ return {
698
+ path: `src/routes/${screen.route}/+page.svelte`,
699
+ description: `${screen.title} screen (${blocks.map((b) => b.config.kind).join(', ') || 'empty'}).`,
700
+ contents: `<script lang="ts">
701
+ ${gridImports}${entImport}${accessImport}${i18nImport}${gotoImport}${pageImport}import { ${schemaVarImports.join(', ')}, ${typeImports.map((t) => `type ${t}`).join(', ')} } from '$lib/schemas'
702
+ import { ${dataImports.join(', ')} } from '$lib/data'
703
+
704
+ ${parts.join('\n\n ')}
705
+ </script>
706
+
707
+ <h1 class="st__title">${title}</h1>
708
+ ${errorBanner}
709
+ ${toolbar}<div class="st-screen">
710
+ ${body}
711
+ </div>${modal}${actionSnippets.length ? '\n\n' + actionSnippets.join('\n\n') : ''}
712
+ ${has(blocks, 'kpi') || has(blocks, 'gauge') || has(blocks, 'tree') ? `
713
+ <style>
714
+ .kpi { position: relative; display: flex; flex-direction: column; gap: 6px; padding: 16px 18px; background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); overflow: hidden; }
715
+ .kpi__head { display: flex; align-items: center; justify-content: space-between; }
716
+ .kpi__label { font-size: 12.5px; font-weight: 600; color: var(--sg-muted, #64748b); text-transform: uppercase; letter-spacing: 0.03em; }
717
+ .kpi__value { font-size: 28px; font-weight: 750; line-height: 1.1; color: var(--sg-fg, #0f172a); }
718
+ .kpi__delta { align-self: flex-start; display: inline-flex; align-items: center; gap: 3px; padding: 2px 8px; border-radius: 999px; font-size: 11.5px; font-weight: 700; background: color-mix(in srgb, var(--sg-muted, #64748b) 14%, transparent); color: var(--sg-muted, #64748b); }
719
+ .kpi__delta.is-up { background: color-mix(in srgb, #16a34a 15%, transparent); color: #16a34a; }
720
+ .kpi__delta.is-down { background: color-mix(in srgb, #dc2626 15%, transparent); color: #dc2626; }
721
+ .kpi__spark { width: 100%; height: 30px; margin-top: 2px; color: var(--sg-accent, #4f46e5); opacity: 0.85; }
722
+ .gaugecard { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 16px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; }
723
+ .gaugecard .kpi__label { align-self: flex-start; }
724
+ .treecard { padding: 12px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; }
725
+ </style>
726
+ ` : ''}`,
727
+ }
728
+ }
729
+
730
+ export function emitStudioProject(project: StudioProject): GeneratedFile[] {
731
+ if (project.entities.length === 0) throw new Error('emitStudioProject: no entities to emit')
732
+ if (project.screens.length === 0) throw new Error('emitStudioProject: no screens to emit')
733
+
734
+ const sources: Record<string, EntityDataSource> = Object.fromEntries(
735
+ project.entities.map((e) => [e.name, entityDataSource(project, e.name)]),
736
+ )
737
+ const accessEnabled = project.access?.enabled === true && (project.access?.roles.length ?? 0) > 0
738
+ // Audit only fires on server routes, so it needs at least one SQL-bound entity.
739
+ const auditEnabled = project.audit === true && Object.values(sources).some((s) => s.kind === 'sql')
740
+ const i18nEnabled = project.i18n?.enabled === true && (project.i18n?.locales.length ?? 0) > 0
741
+ const { files, prepared } = emitEntityModules(project.entities, { sources, accessEnabled, auditEnabled })
742
+ const byName = new Map(prepared.map((s) => [s.name, s]))
743
+ // Raw (unprepared) entities keep their original field set - needed to derive
744
+ // relation display-field names that match withRelationLabels (the prepared
745
+ // schemas already carry the appended display columns, which would false-collide).
746
+ const rawByName = new Map(project.entities.map((e) => [e.name, e]))
747
+ const resolve = (name: string) => byName.get(name)
748
+
749
+ // Drill-through wiring: map screen id -> route, and detect whether any block
750
+ // navigates (so target screens read URL params into an initial filter).
751
+ const routeById = new Map(project.screens.map((s) => [s.id, s.route]))
752
+ const drillEnabled = project.screens.some((s) => s.blocks.some((b) =>
753
+ (b.config.kind === 'grid' && !!b.config.rowLink) || (b.config.kind === 'chart' && !!b.config.drillScreen)))
754
+
755
+ const pages: GeneratedFile[] = []
756
+ const seenRoute = new Set<string>()
757
+ for (const screen of project.screens) {
758
+ const schema = byName.get(screen.entity)
759
+ if (!schema) throw new Error(`emitStudioProject: screen "${screen.title}" references missing entity "${screen.entity}"`)
760
+ if (seenRoute.has(screen.route)) throw new Error(`emitStudioProject: duplicate route "/${screen.route}"`)
761
+ seenRoute.add(screen.route)
762
+ pages.push(screenPage(schema, rawByName.get(screen.entity) ?? schema, screen, resolve, (name) => rawByName.get(name), accessEnabled, i18nEnabled, routeById, drillEnabled))
763
+ }
764
+
765
+ // Nav: only screens flagged into the menu, ordered, with an optional custom label.
766
+ // Carry the screen id so RBAC can hide links the current role can't open.
767
+ const nav: NavItem[] = [...project.screens]
768
+ .filter((s) => s.nav?.show !== false)
769
+ .sort((a, b) => (a.nav?.order ?? 0) - (b.nav?.order ?? 0))
770
+ .map((s) => ({ href: `/${s.route}`, label: s.nav?.label ?? s.title, id: s.id }))
771
+ const accessFiles = accessEnabled ? [accessModule(project)] : []
772
+ const auditFiles = auditEnabled ? [auditModule(), auditRouteFile(), auditViewerPage()] : []
773
+ const navWithAudit = auditEnabled ? [...nav, { href: '/audit', label: 'Audit log', id: '__audit__' }] : nav
774
+ const i18nFiles = i18nEnabled ? [i18nModule(project)] : []
775
+ return [...files, ...accessFiles, ...auditFiles, ...i18nFiles, ...pages, layoutFile(navWithAudit, { accent: project.theme?.accent, shell: project.theme?.shell, title: project.title, themeVars: resolveThemeTokens(project.theme), dark: isDarkTheme(project.theme), access: accessEnabled, i18n: i18nEnabled }), homeFile(navWithAudit)]
776
+ }
777
+
778
+ /** The default-locale (`en`) message catalog, keyed for nav, screen titles, the
779
+ * New button, and grid column headers - the strings the app renders. */
780
+ function buildMessages(project: StudioProject): Record<string, string> {
781
+ const m: Record<string, string> = {}
782
+ for (const s of project.screens) {
783
+ m[`screen.${s.id}`] = s.title
784
+ if (s.nav?.show !== false) m[`nav.${s.id}`] = s.nav?.label ?? s.title
785
+ }
786
+ for (const e of project.entities) {
787
+ m[`new.${e.name}`] = `+ New ${e.label ?? e.name}`
788
+ for (const f of e.fields) m[`col.${e.name}.${f.field}`] = f.label ?? f.field
789
+ }
790
+ if (project.audit === true) m['nav.__audit__'] = 'Audit log'
791
+ return m
792
+ }
793
+
794
+ /** The localization module: locales, the current-locale store, the message
795
+ * catalog (default locale seeded, others left for translators), a reactive
796
+ * `t()` translator, and a `localizeCols` helper for grid headers. */
797
+ function i18nModule(project: StudioProject): GeneratedFile {
798
+ const cfg = project.i18n!
799
+ const locales = cfg.locales
800
+ const def = cfg.defaultLocale && locales.includes(cfg.defaultLocale) ? cfg.defaultLocale : locales[0]!
801
+ const en = buildMessages(project)
802
+ const localeUnion = locales.map((l) => JSON.stringify(l)).join(' | ')
803
+ const seeded = JSON.stringify(en, null, 2).replace(/\n/g, '\n ')
804
+ const messagesEntries = locales.map((l) => ` ${JSON.stringify(l)}: ${l === def ? seeded : '{}'},`).join('\n')
805
+ return {
806
+ path: 'src/lib/i18n.ts',
807
+ description: 'Localization: locales, the current-locale store, the message catalog, and t() / localizeCols helpers.',
808
+ contents: `import { writable, derived } from 'svelte/store'
809
+
810
+ export type Locale = ${localeUnion}
811
+ export const locales: Locale[] = ${JSON.stringify(locales)} as Locale[]
812
+ export const currentLocale = writable<Locale>(${JSON.stringify(def)})
813
+
814
+ // The default locale is seeded from your schema + screen labels. Fill the other
815
+ // locales in with the same keys; missing keys fall back to the default.
816
+ const messages: Record<Locale, Record<string, string>> = {
817
+ ${messagesEntries}
818
+ }
819
+
820
+ /** Reactive translator: \`$t('key', 'fallback')\`. Falls back to the default locale, then the fallback, then the key. */
821
+ export const t = derived(currentLocale, ($l) => (key: string, fallback?: string): string =>
822
+ messages[$l]?.[key] ?? messages[${JSON.stringify(def)}]?.[key] ?? fallback ?? key)
823
+
824
+ /** Localize a column list's headers via \`col.<entity>.<field>\` keys. */
825
+ export function localizeCols<T extends { field?: string | number; header?: string }>(
826
+ cols: T[], entity: string, translate: (k: string, fb?: string) => string,
827
+ ): T[] {
828
+ return cols.map((c) => ({ ...c, header: translate('col.' + entity + '.' + String(c.field), c.header ?? String(c.field ?? '')) }))
829
+ }
830
+ `,
831
+ }
832
+ }
833
+
834
+ /** The audit store: an in-memory `ServerDataSource` of change records + a
835
+ * `recordAudit` writer. Swap the source for SQL/Supabase to persist the trail. */
836
+ function auditModule(): GeneratedFile {
837
+ return {
838
+ path: 'src/lib/audit.ts',
839
+ description: 'Audit trail store: the AuditEntry schema, an in-memory source, and recordAudit(). Swap the source for a DB table to persist it.',
840
+ contents: `import { createInMemoryDataSource } from '@svgrid/enterprise'
841
+ import type { EntitySchema } from '@svgrid/enterprise'
842
+
843
+ export type AuditEntry = {
844
+ id: string
845
+ at: string
846
+ actor: string
847
+ entity: string
848
+ action: 'create' | 'update' | 'delete'
849
+ recordId: string
850
+ summary: string
851
+ }
852
+
853
+ export const auditSchema: EntitySchema<AuditEntry> = {
854
+ name: 'audit',
855
+ idField: 'id',
856
+ fields: [
857
+ { field: 'id', type: 'text', primaryKey: true, readonly: true },
858
+ { field: 'at', type: 'datetime', label: 'When' },
859
+ { field: 'actor', type: 'text', label: 'Actor' },
860
+ { field: 'entity', type: 'text', label: 'Entity' },
861
+ { field: 'action', type: 'enum', label: 'Action', options: [{ value: 'create', label: 'Create' }, { value: 'update', label: 'Update' }, { value: 'delete', label: 'Delete' }] },
862
+ { field: 'recordId', type: 'text', label: 'Record' },
863
+ { field: 'summary', type: 'text', label: 'Summary' },
864
+ ],
865
+ }
866
+
867
+ // In-memory, server-side singleton. Replace with a SQL / Supabase source to
868
+ // persist the trail across restarts (recordAudit + the /audit viewer keep working).
869
+ export const auditSource = createInMemoryDataSource<AuditEntry>([], auditSchema)
870
+ let seq = 0
871
+
872
+ /** Append one change record. Called by the API routes' \`audit\` hook. */
873
+ export async function recordAudit(input: {
874
+ entity: string
875
+ action: 'create' | 'update' | 'delete'
876
+ recordId: string | null
877
+ values?: Record<string, unknown>
878
+ actor?: string
879
+ }): Promise<void> {
880
+ const summary =
881
+ input.action === 'delete'
882
+ ? \`Deleted \${input.entity} \${input.recordId ?? ''}\`.trim()
883
+ : \`\${input.action === 'create' ? 'Created' : 'Updated'} \${input.entity}\${input.values ? ' (' + Object.keys(input.values).join(', ') + ')' : ''}\`
884
+ await auditSource.createRow?.({
885
+ id: String(++seq),
886
+ at: new Date().toISOString(),
887
+ actor: input.actor ?? 'system',
888
+ entity: input.entity,
889
+ action: input.action,
890
+ recordId: input.recordId ?? '',
891
+ summary,
892
+ })
893
+ }
894
+ `,
895
+ }
896
+ }
897
+
898
+ /** The read API for the audit trail (the /audit viewer reads it via the transport). */
899
+ function auditRouteFile(): GeneratedFile {
900
+ return {
901
+ path: 'src/routes/api/audit/+server.ts',
902
+ description: 'API route for the audit trail (read-only viewer feed).',
903
+ contents: `import { createKitHandlers } from '@svgrid/enterprise'
904
+ import { auditSchema, auditSource } from '$lib/audit'
905
+
906
+ export const { POST } = createKitHandlers({ schema: auditSchema, source: auditSource })
907
+ `,
908
+ }
909
+ }
910
+
911
+ /** The audit-log viewer screen: a read-only grid over the audit source. */
912
+ function auditViewerPage(): GeneratedFile {
913
+ return {
914
+ path: 'src/routes/audit/+page.svelte',
915
+ description: 'Audit log viewer (read-only grid of change records).',
916
+ contents: `<script lang="ts">
917
+ import { SvGrid, createServerDataSource, type ServerState } from '@svgrid/grid'
918
+ import { schemaToColumns, createKitDataSource } from '@svgrid/enterprise'
919
+ import { auditSchema, type AuditEntry } from '$lib/audit'
920
+
921
+ const source = createKitDataSource<AuditEntry>({ endpoint: '/api/audit' })
922
+ let view = $state<ServerState<AuditEntry>>({ rows: [], total: 0, loading: false, saving: false, error: null, pageIndex: 0, pageSize: 25, pageCount: 1, sortModel: [], filterModel: {} })
923
+ const controller = createServerDataSource<AuditEntry>(source, { pageSize: 25, onChange: (s) => (view = s) })
924
+ $effect(() => { controller.refresh(); return () => controller.dispose() })
925
+ const columns = schemaToColumns(auditSchema)
926
+ </script>
927
+
928
+ <h1 class="st__title">Audit log</h1>
929
+ <p class="st__sub">Every create, update, and delete recorded server-side.</p>
930
+
931
+ <div class="screen" style="margin-top: 16px">
932
+ <SvGrid
933
+ data={view.rows}
934
+ columns={columns}
935
+ loading={view.loading}
936
+ loadingOverlay
937
+ fitColumns
938
+ sortable
939
+ externalSort
940
+ onSortingChange={(s) => controller.setSort(s)}
941
+ showPagination
942
+ externalPagination
943
+ rowCount={view.total}
944
+ pageIndex={view.pageIndex}
945
+ pageSize={view.pageSize}
946
+ onPaginationChange={({ pageIndex, pageSize }) => (pageSize !== view.pageSize ? controller.setPageSize(pageSize) : controller.setPage(pageIndex))}
947
+ containerHeight={520}
948
+ />
949
+ </div>
950
+ `,
951
+ }
952
+ }
953
+
954
+ /** The shared RBAC policy module: roles, screen + action maps, the current-role
955
+ * store, and the server-side role/authorize helpers. */
956
+ function accessModule(project: StudioProject): GeneratedFile {
957
+ const access = project.access!
958
+ const roleNames = access.roles.map((r) => r.role)
959
+ const defaultRole = access.defaultRole && roleNames.includes(access.defaultRole) ? access.defaultRole : (roleNames[0] ?? 'viewer')
960
+ const roleUnion = roleNames.length ? roleNames.map((r) => JSON.stringify(r)).join(' | ') : "'viewer'"
961
+ const screensEntries = access.roles.map((r) => ` ${JSON.stringify(r.role)}: ${r.screens === '*' ? "'*'" : JSON.stringify(r.screens)},`).join('\n')
962
+ const actionsEntries = access.roles.map((r) => ` ${JSON.stringify(r.role)}: ${r.actions === '*' ? "'*'" : JSON.stringify(r.actions)},`).join('\n')
963
+ return {
964
+ path: 'src/lib/access.ts',
965
+ description: 'RBAC policy: roles, screen + action permissions, the current-role store, and server helpers. Shared by the UI and the API routes.',
966
+ contents: `import { writable } from 'svelte/store'
967
+
968
+ export type AppRole = ${roleUnion}
969
+ export type WriteAction = 'create' | 'update' | 'delete'
970
+ export const ROLES: AppRole[] = ${JSON.stringify(roleNames)} as AppRole[]
971
+
972
+ const SCREENS: Record<AppRole, '*' | string[]> = {
973
+ ${screensEntries}
974
+ }
975
+ const ACTIONS: Record<AppRole, '*' | WriteAction[]> = {
976
+ ${actionsEntries}
977
+ }
978
+
979
+ /** The signed-in user's role. Set it after login (e.g. from the session);
980
+ * defaults to the project's default role. Read it in components as \`$currentRole\`. */
981
+ export const currentRole = writable<AppRole>(${JSON.stringify(defaultRole)})
982
+
983
+ /** May this role open the given screen id? */
984
+ export function canScreen(role: AppRole, screenId: string): boolean {
985
+ const s = SCREENS[role]
986
+ return s === '*' || (Array.isArray(s) && s.includes(screenId))
987
+ }
988
+ /** May this role perform a write action? (Reads are implied by screen access.) */
989
+ export function can(role: AppRole, action: WriteAction): boolean {
990
+ const a = ACTIONS[role]
991
+ return a === '*' || (Array.isArray(a) && a.includes(action))
992
+ }
993
+
994
+ // ---- server side ----------------------------------------------------------
995
+ /** Resolve the caller's role on the server. Wire this to YOUR auth: by default it
996
+ * reads \`event.locals.role\` - set it in \`hooks.server.ts\` from the session. */
997
+ export function getServerRole(event: { locals?: Record<string, unknown> }): AppRole {
998
+ const r = event?.locals?.role
999
+ return (typeof r === 'string' && (ROLES as string[]).includes(r) ? r : ${JSON.stringify(defaultRole)}) as AppRole
1000
+ }
1001
+ /** Authorize a CRUD action for a role - used by the API routes' \`authorize\` hook. */
1002
+ export function authorizeAction(role: AppRole, action: 'read' | WriteAction): boolean {
1003
+ return action === 'read' ? true : can(role, action)
1004
+ }
1005
+ `,
1006
+ }
1007
+ }
1008
+
1009
+ // --- Full runnable app (download / npm-install-and-run) ---------------------
1010
+
1011
+ const appSlug = (title: string): string =>
1012
+ (title || 'studio-app').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'studio-app'
1013
+
1014
+ /** The static SvelteKit + Vite scaffolding around the generated screens. */
1015
+ const SCAFFOLD_STATIC: ReadonlyArray<GeneratedFile> = [
1016
+ { path: 'vite.config.ts', description: 'Vite config.', contents: `import { sveltekit } from '@sveltejs/vite-plugin-svelte'\nimport { defineConfig } from 'vite'\n\nexport default defineConfig({ plugins: [sveltekit()] })\n` },
1017
+ { 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` },
1018
+ { 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` },
1019
+ { path: 'src/app.d.ts', description: 'SvelteKit app types.', contents: `declare global {\n namespace App {}\n}\n\nexport {}\n` },
1020
+ { path: 'src/routes/+layout.ts', description: 'Client SPA (in-memory sources persist across navigation).', contents: `// In-memory sources are module singletons, so render as a client SPA. Move an\n// entity to SQL / Supabase and its /api route still runs server-side.\nexport const ssr = false\nexport const prerender = false\n` },
1021
+ { path: '.npmrc', description: 'npm config.', contents: `engine-strict=true\n` },
1022
+ { path: '.gitignore', description: 'git ignore.', contents: `node_modules\n.svelte-kit\n/build\n.env\n.env.*\n!.env.example\n.DS_Store\n` },
1023
+ ]
1024
+
1025
+ const APP_CSS = `:root { --sg-accent: #4f46e5; color-scheme: light dark; }
1026
+ * { box-sizing: border-box; }
1027
+ html, body { margin: 0; height: 100%; }
1028
+ body { font-family: var(--sg-font, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif); color: var(--sg-fg, #0f172a); background: var(--sg-bg, #fff); }
1029
+ .st__title { margin: 0; font-size: 22px; font-weight: 720; letter-spacing: -0.015em; }
1030
+ .st__sub { margin: 0; font-size: 14px; line-height: 1.6; color: var(--sg-muted, #64748b); max-width: 74ch; }
1031
+ .st__sub code { background: var(--sg-header-bg, #f1f5f9); padding: 1px 6px; border-radius: 5px; font-size: 0.9em; }
1032
+ .st__toolbar { display: flex; align-items: center; gap: 10px; }
1033
+ .st-hint { font-size: 12.5px; color: var(--sg-muted, #94a3b8); }
1034
+ .st-error { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 0 0 16px; padding: 11px 14px; border: 1px solid color-mix(in srgb, var(--sg-danger, #dc2626) 40%, var(--sg-border, #e6e8ec)); border-radius: 10px; background: color-mix(in srgb, var(--sg-danger, #dc2626) 8%, var(--sg-bg, #fff)); color: var(--sg-danger, #b3261e); font-size: 13.5px; }
1035
+ .st-btn { display: inline-flex; align-items: center; gap: 7px; padding: 8px 14px; font: inherit; font-size: 13.5px; font-weight: 560; line-height: 1; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 10px; background: var(--sg-bg, #fff); color: var(--sg-fg, inherit); cursor: pointer; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); }
1036
+ .st-btn:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 5%, var(--sg-bg, #fff)); }
1037
+ .st-btn:disabled { opacity: 0.5; cursor: default; box-shadow: none; }
1038
+ .st-btn--primary { border-color: transparent; color: #fff; background: linear-gradient(180deg, color-mix(in srgb, var(--sg-accent) 88%, #fff), var(--sg-accent)); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.14), 0 8px 18px -9px color-mix(in srgb, var(--sg-accent) 65%, transparent); }
1039
+ .st-btn--primary:hover { filter: brightness(1.06); }
1040
+ .home { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; margin-top: 6px; }
1041
+ .home__card { display: flex; flex-direction: column; gap: 6px; padding: 18px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 14px; text-decoration: none; color: inherit; background: var(--sg-bg, #fff); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); }
1042
+ .home__card:hover { border-color: color-mix(in srgb, var(--sg-accent) 45%, var(--sg-border, #e6e8ec)); }
1043
+ .home__card strong { font-size: 15px; }
1044
+ .home__card span { font-size: 13px; color: var(--sg-muted, #64748b); line-height: 1.5; }
1045
+ .st-filter { display: flex; flex-direction: column; gap: 10px; padding: 14px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; background: var(--sg-bg, #fff); align-self: start; }
1046
+ .st-filter__title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--sg-muted, #64748b); }
1047
+ .st-filter__row { display: flex; flex-direction: column; gap: 4px; font-size: 12.5px; font-weight: 600; color: var(--sg-muted, #64748b); }
1048
+ .st-filter__row select, .st-filter__row input { padding: 7px 9px; font: inherit; font-size: 13px; font-weight: 400; color: var(--sg-fg, inherit); background: var(--sg-input-bg, var(--sg-bg, #fff)); border: 1px solid var(--sg-input-border, var(--sg-border, #e6e8ec)); border-radius: 8px; }
1049
+ .st-record-card { border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; background: var(--sg-bg, #fff); padding: 14px; align-self: start; }
1050
+ .st-record { margin: 0; display: flex; flex-direction: column; gap: 8px; }
1051
+ .st-record__row { display: grid; grid-template-columns: 40% 1fr; gap: 10px; align-items: baseline; border-bottom: 1px solid var(--sg-border, #f1f5f9); padding-bottom: 6px; }
1052
+ .st-record__row dt { margin: 0; font-size: 12px; font-weight: 600; color: var(--sg-muted, #64748b); }
1053
+ .st-record__row dd { margin: 0; font-size: 13.5px; color: var(--sg-fg, inherit); overflow-wrap: anywhere; }
1054
+ .st-screen { display: grid; grid-template-columns: repeat(12, 1fr); gap: 16px; align-items: start; }
1055
+ /* Mobile: blocks stack full-width (a span-N block clamps to the single column). */
1056
+ @media (max-width: 720px) { .st-screen { grid-template-columns: 1fr; gap: 12px; } }
1057
+ @media (max-width: 640px) { .st__title { font-size: 19px; } }
1058
+ .st-rowactions { display: inline-flex; gap: 6px; }
1059
+ .st-rowaction { padding: 3px 9px; font: inherit; font-size: 12px; font-weight: 550; line-height: 1.4; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 7px; background: var(--sg-bg, #fff); color: var(--sg-fg, inherit); cursor: pointer; }
1060
+ .st-rowaction:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 5%, var(--sg-bg, #fff)); }
1061
+ .st-rowaction--danger { color: #dc2626; border-color: color-mix(in srgb, #dc2626 40%, var(--sg-border, #e6e8ec)); }
1062
+ .st-rowaction--danger:hover { background: color-mix(in srgb, #dc2626 8%, var(--sg-bg, #fff)); }
1063
+ `
1064
+
1065
+ /**
1066
+ * Deploy target -> the SvelteKit adapter + provider config the bundle ships, plus
1067
+ * the one-liner and dashboard link the designer's Deploy panel shows. `auto`
1068
+ * (the default) uses adapter-auto, which detects Vercel / Netlify / Cloudflare at
1069
+ * build time; picking a specific target pins the adapter and adds its config so a
1070
+ * `git push` (or the CLI one-liner) deploys with no further setup.
1071
+ */
1072
+ type DeployPlan = {
1073
+ adapterModule: string
1074
+ adapterDep: [name: string, version: string]
1075
+ files: GeneratedFile[]
1076
+ /** Copy-paste command that deploys the app. */
1077
+ cli: string
1078
+ /** Provider "new project" dashboard link (for the import-from-Git path). */
1079
+ dashboard?: string
1080
+ label: string
1081
+ }
1082
+
1083
+ function deployPlan(project: StudioProject): DeployPlan {
1084
+ const slug = appSlug(project.title)
1085
+ const target = project.deploy ?? 'auto'
1086
+ switch (target) {
1087
+ case 'vercel':
1088
+ return {
1089
+ adapterModule: '@sveltejs/adapter-vercel',
1090
+ adapterDep: ['@sveltejs/adapter-vercel', '^5.5.0'],
1091
+ files: [],
1092
+ cli: 'npx vercel --prod',
1093
+ dashboard: 'https://vercel.com/new',
1094
+ label: 'Vercel',
1095
+ }
1096
+ case 'netlify':
1097
+ return {
1098
+ adapterModule: '@sveltejs/adapter-netlify',
1099
+ adapterDep: ['@sveltejs/adapter-netlify', '^5.0.0'],
1100
+ files: [{ path: 'netlify.toml', description: 'Netlify build config.', contents: `[build]\n command = "npm run build"\n` }],
1101
+ cli: 'npx netlify deploy --build --prod',
1102
+ dashboard: 'https://app.netlify.com/start',
1103
+ label: 'Netlify',
1104
+ }
1105
+ case 'cloudflare':
1106
+ return {
1107
+ adapterModule: '@sveltejs/adapter-cloudflare',
1108
+ adapterDep: ['@sveltejs/adapter-cloudflare', '^7.0.0'],
1109
+ files: [{ path: 'wrangler.toml', description: 'Cloudflare Pages config.', contents: `name = "${slug}"\npages_build_output_dir = ".svelte-kit/cloudflare"\ncompatibility_date = "2024-11-01"\n` }],
1110
+ cli: 'npm run build && npx wrangler pages deploy .svelte-kit/cloudflare',
1111
+ dashboard: 'https://dash.cloudflare.com/?to=/:account/pages/new',
1112
+ label: 'Cloudflare Pages',
1113
+ }
1114
+ case 'node':
1115
+ return {
1116
+ adapterModule: '@sveltejs/adapter-node',
1117
+ adapterDep: ['@sveltejs/adapter-node', '^5.2.0'],
1118
+ files: [],
1119
+ cli: 'npm run build && node build',
1120
+ label: 'Node server',
1121
+ }
1122
+ default:
1123
+ return {
1124
+ adapterModule: '@sveltejs/adapter-auto',
1125
+ adapterDep: ['@sveltejs/adapter-auto', '^6.0.0'],
1126
+ files: [],
1127
+ cli: 'npx vercel --prod',
1128
+ dashboard: 'https://vercel.com/new',
1129
+ label: 'Auto (Vercel / Netlify / Cloudflare)',
1130
+ }
1131
+ }
1132
+ }
1133
+
1134
+ /** The deploy facts the designer's Deploy panel shows (label, CLI one-liner, dashboard link). */
1135
+ export function studioDeployInfo(project: StudioProject): { label: string; cli: string; dashboard?: string; adapter: string } {
1136
+ const p = deployPlan(project)
1137
+ return { label: p.label, cli: p.cli, dashboard: p.dashboard, adapter: p.adapterModule }
1138
+ }
1139
+
1140
+ /** A `.env.example` listing the env vars the generated code reads, when any. */
1141
+ function envExample(allSource: string): string | null {
1142
+ const lines: string[] = []
1143
+ if (allSource.includes('env.DATABASE_URL')) {
1144
+ lines.push('# Your database connection string (Neon / Supabase / Postgres / MySQL / SQLite path).')
1145
+ lines.push('DATABASE_URL=')
1146
+ }
1147
+ if (allSource.includes('env.DATABASE_AUTH_TOKEN')) {
1148
+ lines.push('# Turso / libSQL database auth token.')
1149
+ lines.push('DATABASE_AUTH_TOKEN=')
1150
+ }
1151
+ if (lines.length === 0) return null
1152
+ lines.push('')
1153
+ lines.push('# Optional: your SvGrid license key removes the unlicensed watermark.')
1154
+ lines.push('# VITE_SVPRO_KEY=')
1155
+ return lines.join('\n') + '\n'
1156
+ }
1157
+
1158
+ function svelteConfig(plan: DeployPlan): string {
1159
+ 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`
1160
+ }
1161
+
1162
+ function packageJson(project: StudioProject, allSource: string): string {
1163
+ const dependencies: Record<string, string> = { '@svgrid/grid': 'latest', '@svgrid/enterprise': 'latest' }
1164
+ if (allSource.includes("from '@supabase/supabase-js'")) dependencies['@supabase/supabase-js'] = '^2.45.0'
1165
+ if (allSource.includes("import pg from 'pg'")) dependencies['pg'] = '^8.11.0'
1166
+ if (allSource.includes("from 'mysql2/promise'")) dependencies['mysql2'] = '^3.9.0'
1167
+ if (allSource.includes("import mssql from 'mssql'")) dependencies['mssql'] = '^10.0.0'
1168
+ if (allSource.includes("import Database from 'better-sqlite3'")) dependencies['better-sqlite3'] = '^11.0.0'
1169
+ if (allSource.includes("from '@libsql/client'")) dependencies['@libsql/client'] = '^0.14.0'
1170
+ if (allSource.includes("from '@electric-sql/pglite'")) dependencies['@electric-sql/pglite'] = '^0.5.0'
1171
+ const pkg = {
1172
+ name: appSlug(project.title),
1173
+ version: '0.0.1',
1174
+ private: true,
1175
+ type: 'module',
1176
+ scripts: { dev: 'vite dev', build: 'vite build', preview: 'vite preview', check: 'svelte-kit sync && svelte-check --tsconfig ./tsconfig.json', test: 'vitest run' },
1177
+ dependencies,
1178
+ devDependencies: {
1179
+ [deployPlan(project).adapterDep[0]]: deployPlan(project).adapterDep[1],
1180
+ '@sveltejs/kit': '^2.15.0',
1181
+ '@sveltejs/vite-plugin-svelte': '^7.0.0',
1182
+ svelte: '^5.55.5',
1183
+ 'svelte-check': '^4.4.6',
1184
+ typescript: '^5.7.0',
1185
+ vite: '^8.0.10',
1186
+ vitest: '^4.1.5',
1187
+ },
1188
+ }
1189
+ return JSON.stringify(pkg, null, 2) + '\n'
1190
+ }
1191
+
1192
+ /**
1193
+ * A per-entity smoke test shipped with the generated app. It proves the two
1194
+ * things a screen depends on still hold: every schema yields grid columns +
1195
+ * form fields (so the page renders), and the schema round-trips through the
1196
+ * data-source layer (create -> read -> delete). It builds a fresh in-memory
1197
+ * source from the schema, so it runs offline regardless of the real backend
1198
+ * (SQL / Supabase / PGlite) - a fast regression guard on schema edits.
1199
+ */
1200
+ function smokeTestFile(project: StudioProject): string {
1201
+ const imports = project.entities.map((e) => {
1202
+ const n = namesFor(e)
1203
+ return `${n.schemaVar}, type ${n.type}`
1204
+ })
1205
+ const blocks = project.entities.map((e) => {
1206
+ const n = namesFor(e)
1207
+ return `describe(${JSON.stringify(n.label)}, () => {
1208
+ it('exposes grid columns and form fields', () => {
1209
+ expect(schemaToColumns(${n.schemaVar}).length).toBeGreaterThan(0)
1210
+ expect(schemaToFormFields(${n.schemaVar}).length).toBeGreaterThan(0)
1211
+ })
1212
+
1213
+ it('round-trips create -> read -> delete through an in-memory source', async () => {
1214
+ const idField = ${n.schemaVar}.idField ?? ${n.schemaVar}.fields.find((f) => f.primaryKey)?.field ?? 'id'
1215
+ const source = createInMemoryDataSource<${n.type}>([], ${n.schemaVar})
1216
+ await source.createRow({ [idField]: 'smoke-1' } as unknown as Partial<${n.type}>)
1217
+ expect((await source.getRows({ startRow: 0, endRow: 100, pageIndex: 0, pageSize: 100, sortModel: [], filterModel: {} })).rowCount).toBe(1)
1218
+ await source.deleteRow('smoke-1')
1219
+ expect((await source.getRows({ startRow: 0, endRow: 100, pageIndex: 0, pageSize: 100, sortModel: [], filterModel: {} })).rowCount).toBe(0)
1220
+ })
1221
+ })`
1222
+ })
1223
+ return `// Smoke tests generated by SvGrid Studio. Run with \`npm test\`.
1224
+ // They prove every entity's schema still renders (grid columns + form fields)
1225
+ // and round-trips through the data-source layer, so a schema edit that would
1226
+ // break a screen fails here first. Regenerating the app refreshes this file.
1227
+ import { describe, it, expect } from 'vitest'
1228
+ import { schemaToColumns, schemaToFormFields, createInMemoryDataSource } from '@svgrid/enterprise'
1229
+ import { ${imports.join(', ')} } from './schemas'
1230
+
1231
+ ${blocks.join('\n\n')}
1232
+ `
1233
+ }
1234
+
1235
+ const VITEST_CONFIG = `import { defineConfig } from 'vitest/config'
1236
+
1237
+ // Node-only test runner for the generated smoke tests (no Svelte/DOM needed).
1238
+ // Kept separate from vite.config so the SvelteKit plugin doesn't load here.
1239
+ export default defineConfig({
1240
+ test: { environment: 'node', include: ['src/**/*.test.ts'] },
1241
+ })
1242
+ `
1243
+
1244
+ /**
1245
+ * Emit the COMPLETE runnable SvelteKit + Vite app: the generated screens/data
1246
+ * plus all scaffolding (package.json, vite/svelte/ts config, app shell, css).
1247
+ * Download it, `npm install`, `npm run dev`. This is what the designer's
1248
+ * "Download .zip" produces.
1249
+ */
1250
+ export function emitStudioAppBundle(project: StudioProject): GeneratedFile[] {
1251
+ const generated = emitStudioProject(project)
1252
+ const allSource = generated.map((f) => f.contents).join('\n')
1253
+ const plan = deployPlan(project)
1254
+ const deploySteps = plan.dashboard
1255
+ ? `1. Push this folder to a Git repo (GitHub / GitLab / Bitbucket).\n2. Import it at <${plan.dashboard}> - build settings are detected automatically.\n\nOr deploy straight from your machine with the CLI:\n\n\`\`\`bash\n${plan.cli}\n\`\`\``
1256
+ : `Build and run the server:\n\n\`\`\`bash\n${plan.cli}\n\`\`\``
1257
+ const readme = `# ${project.title}\n\nGenerated with SvGrid Studio.\n\n\`\`\`bash\nnpm install\nnpm run dev\n\`\`\`\n\nSQL / Supabase entities read their connection from \`.env\` (\`DATABASE_URL\`) or\n\`src/lib/connections.ts\`. Everything else runs on seeded in-memory data.\n\n## Deploy (${plan.label})\n\nThis app is configured for **${plan.label}** (SvelteKit \`${plan.adapterModule}\`).\n\n${deploySteps}\n\nTo target a different host, pick another **Deploy target** in the designer and\nre-generate, or swap the adapter in \`svelte.config.js\`. Entities on **Local\ndatabase** (PGlite) or **In-memory** need no server env; SQL / Supabase entities\nneed their connection set in the host's environment variables.\n\n## Round-tripping back into the designer\n\nThis app ships its own design model in \`studio.config.json\`. To keep editing\nvisually, open the SvGrid Studio designer and **Load** that file - your entities,\nscreens, blocks, theme, RBAC, i18n, etc. come back exactly as generated, and you\ncan re-generate from there.\n\nThe designer regenerates the files under \`src/routes\` and \`src/lib\` from the\nmodel, so **keep your own custom code in new files/modules and import it**, rather\nthan editing the generated screens in place - that way a re-generate never\nclobbers your work. (The CLI workflow, \`npx @svgrid/studio add\`, is the\nalternative: it wraps generated code in \`svgrid:managed\` markers and preserves\nanything you write outside them.)\n`
1258
+ const scaffold: GeneratedFile[] = [
1259
+ { path: 'package.json', description: 'Dependencies + scripts (npm install, npm run dev).', contents: packageJson(project, allSource) },
1260
+ { path: 'svelte.config.js', description: `SvelteKit config (${plan.adapterModule}).`, contents: svelteConfig(plan) },
1261
+ { path: 'vitest.config.ts', description: 'Test runner config for the generated smoke tests (npm test).', contents: VITEST_CONFIG },
1262
+ { path: 'src/lib/schemas.test.ts', description: 'Smoke tests: every entity renders + round-trips through its data source.', contents: smokeTestFile(project) },
1263
+ ...plan.files,
1264
+ ...SCAFFOLD_STATIC,
1265
+ ...(envExample(allSource) ? [{ path: '.env.example', description: 'Environment variables the app reads (copy to .env and fill in).', contents: envExample(allSource)! }] : []),
1266
+ { path: 'src/app.css', description: 'App theme + page styles.', contents: APP_CSS + (project.theme?.customCss ? `\n\n/* --- Custom CSS (from the designer) --- */\n${project.theme.customCss}\n` : '') },
1267
+ // The design model, shipped with the app so it can be re-imported (Load) into
1268
+ // the designer for further visual editing - the export/import round-trip.
1269
+ { path: 'studio.config.json', description: 'The Studio project model - Load it back into the designer to keep editing visually.', contents: serializeProject(project) + '\n' },
1270
+ { path: 'README.md', description: 'How to run the app.', contents: readme },
1271
+ ]
1272
+ return [...scaffold, ...generated]
1273
+ }