@svgrid/enterprise 2.0.3 → 2.2.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 (50) hide show
  1. package/README.md +18 -6
  2. package/dist/cdn/svgrid-enterprise.svelte-external.js +14025 -6836
  3. package/dist/node/studio.js +7889 -2460
  4. package/package.json +9 -4
  5. package/src/SvGridMasterDetail.svelte +24 -3
  6. package/src/SvGridScheduler.svelte +4410 -0
  7. package/src/SvPivotDesigner.svelte +1990 -1045
  8. package/src/SvSchemaChart.svelte +10 -9
  9. package/src/ai.test.ts +522 -522
  10. package/src/ai.ts +202 -2
  11. package/src/index.ts +409 -384
  12. package/src/install.ts +10 -0
  13. package/src/pivot-chart.test.ts +86 -0
  14. package/src/pivot-chart.ts +112 -0
  15. package/src/scheduler.ts +37 -0
  16. package/src/scheduling.test.ts +194 -0
  17. package/src/scheduling.ts +293 -0
  18. package/src/sources/filters.ts +6 -0
  19. package/src/studio/HANDLERS-DESIGN.md +142 -0
  20. package/src/studio/cli.ts +7 -2
  21. package/src/studio/emit-project.test.ts +1447 -13
  22. package/src/studio/emit-project.ts +3995 -1273
  23. package/src/studio/emit-schema.ts +146 -29
  24. package/src/studio/index.ts +320 -195
  25. package/src/studio/project.test.ts +370 -0
  26. package/src/studio/project.ts +1146 -26
  27. package/src/studio/sample-data.ts +4 -1
  28. package/src/studio/samples/ats.ts +2 -2
  29. package/src/studio/samples/clinic.ts +4 -2
  30. package/src/studio/samples/crm.ts +16 -8
  31. package/src/studio/samples/events.ts +4 -2
  32. package/src/studio/samples/fleet.ts +4 -2
  33. package/src/studio/samples/gym.ts +4 -2
  34. package/src/studio/samples/hr.ts +3 -1
  35. package/src/studio/samples/live-data.ts +308 -308
  36. package/src/studio/samples/projects.ts +2 -2
  37. package/src/studio/samples/restaurant.ts +4 -2
  38. package/src/studio/samples/samples.test.ts +13 -5
  39. package/src/studio/samples/shared.ts +346 -305
  40. package/src/studio/samples/support.ts +3 -1
  41. package/src/studio/scaffold.test.ts +15 -1
  42. package/src/studio/scaffold.ts +16 -0
  43. package/src/studio/themes.ts +7 -0
  44. package/src/studio/ui-components.ts +472 -0
  45. package/src/sveltekit/transport.test.ts +26 -0
  46. package/src/sveltekit/transport.ts +50 -5
  47. package/dist/designer/assets/index-Dp44bTid.js +0 -939
  48. package/dist/designer/assets/index-RJp6x8tw.css +0 -1
  49. package/dist/designer/assets/jszip.min-CjMo-QGg.js +0 -2
  50. package/dist/designer/index.html +0 -13
@@ -1,305 +1,346 @@
1
- /**
2
- * Shared helpers for the curated sample apps. A `SampleApp` is a ready-made
3
- * `StudioProject` (entities + screens + curated seed + theme) that a user loads
4
- * to see a beautiful, working multi-entity app in a couple of clicks.
5
- *
6
- * Pure + node-safe (studio subtree): each sample is plain data built from the
7
- * same immutable ops the designer uses, so it round-trips through
8
- * parse/serialize and generates a runnable app with no special-casing.
9
- */
10
- import type { EntitySchema } from '../../schema.js'
11
- import type { ChartType } from '@svgrid/grid'
12
- import { generateValue } from '../sample-data.js'
13
- import { defaultBlockConfig, sanitizeProject, screenFromTemplate, type Block, type BlockConfig, type DetailRelated, type FormatRule, type GridConfig, type GridDensity, type KpiFormat, type Reduce, type RowAction, type RowLink, type Screen, type ScreenTemplate, type StudioProject, type EntityDataSource } from '../project.js'
14
-
15
- type Row = Record<string, unknown>
16
-
17
- /**
18
- * Extend a curated seed to `target` rows so grids scroll and charts / KPIs look
19
- * substantial (real dashboards have data). The hand-authored rows stay first;
20
- * extra rows get realistic generated values, valid ids, and foreign keys drawn
21
- * from `fkPools` (relation field -> list of parent ids).
22
- */
23
- export function pad(schema: EntitySchema, rows: Row[], target: number, fkPools: Record<string, string[]> = {}): Row[] {
24
- if (rows.length >= target) return rows
25
- const pk = schema.idField ?? schema.fields.find((f) => f.primaryKey)?.field ?? 'id'
26
- const prefix = String(rows[0]?.[pk] ?? 'r').replace(/\d+$/, '') || 'r'
27
- // Title-like text fields (a record's display name) must stay ON-DOMAIN - the
28
- // generic generator would put "Premium Backpack" on a property board. So cycle
29
- // the entity's own curated seed values (with a numeric suffix on wrap) instead.
30
- // Exclude identifier-shaped fields (ref / number / sku / code) which have formats.
31
- const firstText = schema.fields.find((f) => f.type === 'text' && f.field !== pk && !/id$|ref|no$|number|code|sku/i.test(f.field))?.field
32
- const titleLike = new Set(schema.fields.filter((f) => f.type === 'text' && (/^(name|title|subject|headline|label)$/i.test(f.field) || f.field === firstText)).map((f) => f.field))
33
- const seedVals = (field: string) => rows.map((r) => r[field]).filter((v) => v != null && v !== '')
34
- const out = [...rows]
35
- for (let i = rows.length; i < target; i++) {
36
- const row: Row = {}
37
- for (const f of schema.fields) {
38
- if (f.field === pk) { row[f.field] = `${prefix}${i + 1}`; continue }
39
- if (f.computed || f.formula) continue // derived at runtime - never seeded
40
- if (f.type === 'relation') {
41
- const pool = fkPools[f.field] ?? []
42
- row[f.field] = pool.length ? pool[i % pool.length] : (rows[i % rows.length]?.[f.field] ?? '')
43
- continue
44
- }
45
- if (titleLike.has(f.field)) {
46
- const vals = seedVals(f.field)
47
- if (vals.length) { const round = Math.floor(i / vals.length); row[f.field] = round === 0 ? vals[i] : `${vals[i % vals.length]} ${round + 1}`; continue }
48
- }
49
- row[f.field] = generateValue(f, i)
50
- }
51
- out.push(row)
52
- }
53
- return out
54
- }
55
-
56
- /** The `id` values of a seed array (for building foreign-key pools). */
57
- export const ids = (rows: Row[]): string[] => rows.map((r) => String(r.id))
58
-
59
- /** A ready-made app shown in the gallery. */
60
- export type SampleApp = {
61
- /** Stable id (also the `--template` value). */
62
- id: string
63
- /** Display name, e.g. "CRM". */
64
- name: string
65
- /** One-line description for the gallery card. */
66
- description: string
67
- /** A single emoji used as the card icon. */
68
- emoji: string
69
- /** Accent color (themes the built app). */
70
- accent: string
71
- /** Build the full project (fresh each call). */
72
- build: () => StudioProject
73
- }
74
-
75
- /** Rich-grid options an enterprise screen layers onto the default grid: status
76
- * pills / thresholds (formatRules), a totals row, row actions, row-click drill,
77
- * and density. */
78
- export type GridOpts = {
79
- format?: FormatRule[]
80
- rowActions?: RowAction[]
81
- rowLink?: RowLink
82
- summaries?: boolean
83
- density?: GridDensity
84
- pageSize?: number
85
- }
86
-
87
- /** A dashboard tile: KPI card (with trend/target/format), chart, gauge, pivot,
88
- * tree, a rich grid, or a tabbed group of display tiles. */
89
- export type Tile =
90
- | { kpi: string; measure?: string; reduce: Reduce; format?: KpiFormat; trendField?: string; trendReduce?: Reduce; target?: number; span?: 1 | 2 | 3 }
91
- | { chart: string; measure?: string; reduce?: Reduce; type?: ChartType; span?: 1 | 2 | 3 }
92
- | { gauge: string; measure?: string; reduce: Reduce; min?: number; max?: number; unit?: string; span?: 1 | 2 | 3 }
93
- | { pivot: { rows: string[]; cols: string[]; measure?: string; aggregate?: Reduce }; span?: 1 | 2 | 3 }
94
- | { tree: { labelField: string; parentField: string }; span?: 1 | 2 | 3 }
95
- | ({ grid: true; span?: 1 | 2 | 3 } & GridOpts)
96
- | { filter: string[]; span?: 1 | 2 | 3 }
97
- | { tabs: { label: string; tiles: Tile[] }[]; span?: 1 | 2 | 3 }
98
-
99
- /** The default grid config with enterprise options merged on. */
100
- function gridConfig(entity: EntitySchema, opts: GridOpts): BlockConfig {
101
- const base = defaultBlockConfig('grid', entity) as GridConfig
102
- return {
103
- ...base,
104
- ...(opts.format ? { formatRules: opts.format } : {}),
105
- ...(opts.rowActions ? { rowActions: opts.rowActions } : {}),
106
- ...(opts.rowLink ? { rowLink: opts.rowLink } : {}),
107
- ...(opts.summaries ? { rowSummaries: true } : {}),
108
- ...(opts.density ? { density: opts.density } : {}),
109
- ...(opts.pageSize ? { pageSize: opts.pageSize } : {}),
110
- }
111
- }
112
-
113
- /** One dashboard tile -> a Block. Recurses for `tabs` (whose children must be
114
- * display blocks: chart / kpi / gauge / pivot / tree). */
115
- function tileBlock(entity: EntitySchema, t: Tile, i: number): Block {
116
- const id = `blk-${i + 1}`
117
- if ('kpi' in t) {
118
- const config: BlockConfig = {
119
- kind: 'kpi', label: t.kpi, ...(t.measure ? { measure: t.measure } : {}), reduce: t.reduce,
120
- ...(t.format ? { format: t.format } : {}), ...(t.trendField ? { trendField: t.trendField } : {}),
121
- ...(t.trendReduce ? { trendReduce: t.trendReduce } : {}), ...(t.target != null ? { target: t.target } : {}),
122
- }
123
- return { id, span: t.span ?? 1, config }
124
- }
125
- if ('chart' in t) {
126
- return { id, span: t.span ?? 2, config: { kind: 'chart', dimension: t.chart, ...(t.measure ? { measure: t.measure } : {}), reduce: t.reduce ?? 'sum', type: t.type ?? 'bar' } }
127
- }
128
- if ('gauge' in t) {
129
- return { id, span: t.span ?? 1, config: { kind: 'gauge', label: t.gauge, ...(t.measure ? { measure: t.measure } : {}), reduce: t.reduce, min: t.min ?? 0, max: t.max ?? 100, ...(t.unit ? { unit: t.unit } : {}) } }
130
- }
131
- if ('pivot' in t) {
132
- return { id, span: t.span ?? 3, config: { kind: 'pivot', rows: t.pivot.rows, cols: t.pivot.cols, ...(t.pivot.measure ? { measure: t.pivot.measure } : {}), aggregate: t.pivot.aggregate ?? 'sum' } }
133
- }
134
- if ('tree' in t) {
135
- return { id, span: t.span ?? 1, config: { kind: 'tree', labelField: t.tree.labelField, parentField: t.tree.parentField } }
136
- }
137
- if ('filter' in t) {
138
- return { id, span: t.span ?? 3, config: { kind: 'filter', fields: t.filter } }
139
- }
140
- if ('tabs' in t) {
141
- return { id, span: t.span ?? 3, config: { kind: 'tabs', tabs: t.tabs.map((tab) => ({ label: tab.label, blocks: tab.tiles.map((tt, j) => tileBlock(entity, tt, j)) })) } }
142
- }
143
- return { id, span: t.span ?? 3, config: gridConfig(entity, t) }
144
- }
145
-
146
- /** Compose a dashboard screen from explicit tiles (KPI cards + gauges + charts +
147
- * pivots + trees + a rich grid). */
148
- export function dashScreen(
149
- entity: EntitySchema,
150
- meta: { id: string; title: string; order: number },
151
- tiles: Tile[],
152
- ): Screen {
153
- const blocks = tiles.map((t, i) => tileBlock(entity, t, i))
154
- return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
155
- }
156
-
157
- /** FormatRule[] that color-code an enum field's cells by its option colors -
158
- * turns a plain status column into enterprise status pills. */
159
- export function statusPills(entity: EntitySchema, field: string): FormatRule[] {
160
- const f = entity.fields.find((x) => x.field === field)
161
- if (!f?.options) return []
162
- return f.options
163
- .filter((o) => o.color)
164
- .map((o) => ({ field, op: 'eq' as const, value: o.value as string | number, background: `color-mix(in srgb, ${o.color} 18%, transparent)`, color: o.color, bold: true }))
165
- }
166
-
167
- /** A list screen: an optional faceted filter panel + a rich grid (status pills,
168
- * totals, row actions, drill-through). */
169
- export function listScreen(
170
- entity: EntitySchema,
171
- meta: { id: string; title: string; order: number },
172
- opts: { filter?: string[]; grid?: GridOpts } = {},
173
- ): Screen {
174
- const blocks: Block[] = []
175
- if (opts.filter?.length) blocks.push({ id: 'filter-1', span: 3, config: { kind: 'filter', fields: opts.filter } })
176
- blocks.push({ id: 'grid-1', span: 3, config: gridConfig(entity, opts.grid ?? {}) })
177
- return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
178
- }
179
-
180
- /**
181
- * A form-showcase screen: a grid to pick a row + an editable record panel that
182
- * renders the full field form (phone / rating / tags / mask / slider editors)
183
- * inline. Lets a user see and try the rich editors on load, not just on edit.
184
- */
185
- export function formScreen(
186
- entity: EntitySchema,
187
- meta: { id: string; title: string; order: number },
188
- fields?: string[],
189
- grid?: GridOpts,
190
- filter?: string[],
191
- ): Screen {
192
- const blocks: Block[] = []
193
- if (filter?.length) blocks.push({ id: 'filter-1', span: 3, config: { kind: 'filter', fields: filter } })
194
- blocks.push({ id: 'grid-1', span: 3, config: gridConfig(entity, grid ?? {}) })
195
- blocks.push({ id: 'record-1', span: 3, config: { kind: 'record', editable: true, ...(fields ? { fields } : {}) } })
196
- return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
197
- }
198
-
199
- /** A Kanban board screen: an optional filter panel + a board grouping rows by an
200
- * enum field into draggable columns (the signature "pipeline" view). */
201
- export function boardScreen(
202
- entity: EntitySchema,
203
- meta: { id: string; title: string; order: number },
204
- opts: { groupBy: string; titleField: string; badgeField?: string; subtitleField?: string; filter?: string[]; openScreen?: string },
205
- ): Screen {
206
- const blocks: Block[] = []
207
- if (opts.filter?.length) blocks.push({ id: 'filter-1', span: 3, config: { kind: 'filter', fields: opts.filter } })
208
- blocks.push({ id: 'board-1', span: 3, config: {
209
- kind: 'board', groupBy: opts.groupBy, titleField: opts.titleField,
210
- ...(opts.badgeField ? { badgeField: opts.badgeField } : {}),
211
- ...(opts.subtitleField ? { subtitleField: opts.subtitleField } : {}),
212
- ...(opts.openScreen ? { openScreen: opts.openScreen } : {}),
213
- } })
214
- return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
215
- }
216
-
217
- /** A calendar screen: an optional filter + a month event-calendar placing rows by
218
- * a date field (the signature "schedule" view). */
219
- export function calendarScreen(
220
- entity: EntitySchema,
221
- meta: { id: string; title: string; order: number },
222
- opts: { dateField: string; titleField: string; colorField?: string; filter?: string[]; openScreen?: string },
223
- ): Screen {
224
- const blocks: Block[] = []
225
- if (opts.filter?.length) blocks.push({ id: 'filter-1', span: 3, config: { kind: 'filter', fields: opts.filter } })
226
- blocks.push({ id: 'calendar-1', span: 3, config: {
227
- kind: 'calendar', dateField: opts.dateField, titleField: opts.titleField,
228
- ...(opts.colorField ? { colorField: opts.colorField } : {}),
229
- ...(opts.openScreen ? { openScreen: opts.openScreen } : {}),
230
- } })
231
- return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
232
- }
233
-
234
- /** A record detail-page screen: a full record view (header + status pill + metric
235
- * tiles + tabbed Overview / related timelines) - the signature view for
236
- * relation-heavy records (a customer / deal / patient / trip page). */
237
- export function detailScreen(
238
- entity: EntitySchema,
239
- meta: { id: string; title: string; order: number },
240
- opts: { titleField: string; subtitleField?: string; statusField?: string; metricFields?: string[]; sections?: { label: string; fields: string[] }[]; related?: DetailRelated[] },
241
- ): Screen {
242
- const blocks: Block[] = [{ id: 'detail-1', span: 3, config: {
243
- kind: 'detail', titleField: opts.titleField,
244
- ...(opts.subtitleField ? { subtitleField: opts.subtitleField } : {}),
245
- ...(opts.statusField ? { statusField: opts.statusField } : {}),
246
- ...(opts.metricFields?.length ? { metricFields: opts.metricFields } : {}),
247
- ...(opts.sections?.length ? { sections: opts.sections } : {}),
248
- ...(opts.related?.length ? { related: opts.related } : {}),
249
- } }]
250
- return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
251
- }
252
-
253
- /** Build a screen from a template, then override its id / route / title / nav.
254
- * `linkScreen` wires a master-detail parent row to drill into a detail screen. */
255
- export function screen(
256
- entity: EntitySchema,
257
- template: ScreenTemplate,
258
- over: { id: string; title: string; route?: string; order: number; child?: EntitySchema; foreignKey?: string; linkScreen?: string },
259
- ): Screen {
260
- const base = screenFromTemplate(entity, template, { child: over.child, foreignKey: over.foreignKey })
261
- const blocks = over.linkScreen
262
- ? base.blocks.map((b) => (b.config.kind === 'master-detail' ? { ...b, config: { ...b.config, linkScreen: over.linkScreen } } : b))
263
- : base.blocks
264
- return {
265
- ...base,
266
- blocks,
267
- id: over.id,
268
- title: over.title,
269
- route: over.route ?? over.id,
270
- nav: { show: true, label: over.title, order: over.order },
271
- }
272
- }
273
-
274
- /** Assemble a sample project: entities + screens + per-entity curated seed + theme/shell. */
275
- export function project(opts: {
276
- title: string
277
- brand: string
278
- accent: string
279
- footer?: string
280
- navStyle?: 'sidebar' | 'top-nav'
281
- navPosition?: 'left' | 'right'
282
- /** A design-system preset id (font + radius + palette) from `studioThemes` -
283
- * what makes each app read as a distinct product (Salesforce vs Excel vs Linear). */
284
- preset?: string
285
- mode?: 'light' | 'dark'
286
- entities: EntitySchema[]
287
- screens: Screen[]
288
- seed: Record<string, Record<string, unknown>[]>
289
- }): StudioProject {
290
- const dataSources: Record<string, EntityDataSource> = {}
291
- for (const e of opts.entities) dataSources[e.name] = { kind: 'memory', seed: opts.seed[e.name] ?? [] }
292
- return sanitizeProject({
293
- title: opts.title,
294
- entities: opts.entities,
295
- screens: opts.screens,
296
- dataSource: 'memory',
297
- dataSources,
298
- theme: {
299
- accent: opts.accent,
300
- ...(opts.preset ? { preset: opts.preset } : {}),
301
- ...(opts.mode ? { mode: opts.mode } : {}),
302
- shell: { style: opts.navStyle ?? 'sidebar', brand: opts.brand, footer: opts.footer ?? '', navPosition: opts.navPosition ?? 'left' },
303
- },
304
- })
305
- }
1
+ /**
2
+ * Shared helpers for the curated sample apps. A `SampleApp` is a ready-made
3
+ * `StudioProject` (entities + screens + curated seed + theme) that a user loads
4
+ * to see a beautiful, working multi-entity app in a couple of clicks.
5
+ *
6
+ * Pure + node-safe (studio subtree): each sample is plain data built from the
7
+ * same immutable ops the designer uses, so it round-trips through
8
+ * parse/serialize and generates a runnable app with no special-casing.
9
+ */
10
+ import type { EntitySchema } from '../../schema.js'
11
+ import type { ChartType } from '@svgrid/grid'
12
+ import { generateValue } from '../sample-data.js'
13
+ import { defaultBlockConfig, sanitizeProject, screenFromTemplate, buildDockLayout, type Block, type BlockConfig, type DetailRelated, type FormatRule, type GridConfig, type GridDensity, type KpiFormat, type Reduce, type RowAction, type RowLink, type Screen, type ScreenTemplate, type StudioProject, type EntityDataSource, type ShellStyle, type SchedulerViewConfig, type SchedulerViewMode } from '../project.js'
14
+
15
+ type Row = Record<string, unknown>
16
+
17
+ /**
18
+ * Extend a curated seed to `target` rows so grids scroll and charts / KPIs look
19
+ * substantial (real dashboards have data). The hand-authored rows stay first;
20
+ * extra rows get realistic generated values, valid ids, and foreign keys drawn
21
+ * from `fkPools` (relation field -> list of parent ids).
22
+ */
23
+ export function pad(schema: EntitySchema, rows: Row[], target: number, fkPools: Record<string, string[]> = {}): Row[] {
24
+ if (rows.length >= target) return rows
25
+ const pk = schema.idField ?? schema.fields.find((f) => f.primaryKey)?.field ?? 'id'
26
+ const prefix = String(rows[0]?.[pk] ?? 'r').replace(/\d+$/, '') || 'r'
27
+ // Title-like text fields (a record's display name) must stay ON-DOMAIN - the
28
+ // generic generator would put "Premium Backpack" on a property board. So cycle
29
+ // the entity's own curated seed values (with a numeric suffix on wrap) instead.
30
+ // Exclude identifier-shaped fields (ref / number / sku / code) which have formats.
31
+ const firstText = schema.fields.find((f) => f.type === 'text' && f.field !== pk && !/id$|ref|no$|number|code|sku/i.test(f.field))?.field
32
+ const titleLike = new Set(schema.fields.filter((f) => f.type === 'text' && (/^(name|title|subject|headline|label)$/i.test(f.field) || f.field === firstText)).map((f) => f.field))
33
+ const seedVals = (field: string) => rows.map((r) => r[field]).filter((v) => v != null && v !== '')
34
+ const out = [...rows]
35
+ for (let i = rows.length; i < target; i++) {
36
+ const row: Row = {}
37
+ for (const f of schema.fields) {
38
+ if (f.field === pk) { row[f.field] = `${prefix}${i + 1}`; continue }
39
+ if (f.computed || f.formula) continue // derived at runtime - never seeded
40
+ if (f.type === 'relation') {
41
+ const pool = fkPools[f.field] ?? []
42
+ row[f.field] = pool.length ? pool[i % pool.length] : (rows[i % rows.length]?.[f.field] ?? '')
43
+ continue
44
+ }
45
+ if (titleLike.has(f.field)) {
46
+ const vals = seedVals(f.field)
47
+ if (vals.length) { const round = Math.floor(i / vals.length); row[f.field] = round === 0 ? vals[i] : `${vals[i % vals.length]} ${round + 1}`; continue }
48
+ }
49
+ row[f.field] = generateValue(f, i)
50
+ }
51
+ out.push(row)
52
+ }
53
+ return out
54
+ }
55
+
56
+ /** The `id` values of a seed array (for building foreign-key pools). */
57
+ export const ids = (rows: Row[]): string[] => rows.map((r) => String(r.id))
58
+
59
+ /** A ready-made app shown in the gallery. */
60
+ export type SampleApp = {
61
+ /** Stable id (also the `--template` value). */
62
+ id: string
63
+ /** Display name, e.g. "CRM". */
64
+ name: string
65
+ /** One-line description for the gallery card. */
66
+ description: string
67
+ /** A single emoji used as the card icon. */
68
+ emoji: string
69
+ /** Accent color (themes the built app). */
70
+ accent: string
71
+ /** Build the full project (fresh each call). */
72
+ build: () => StudioProject
73
+ }
74
+
75
+ /** Rich-grid options an enterprise screen layers onto the default grid: status
76
+ * pills / thresholds (formatRules), a totals row, row actions, row-click drill,
77
+ * and density. */
78
+ export type GridOpts = {
79
+ format?: FormatRule[]
80
+ rowActions?: RowAction[]
81
+ rowLink?: RowLink
82
+ summaries?: boolean
83
+ density?: GridDensity
84
+ pageSize?: number
85
+ }
86
+
87
+ /** A dashboard tile: KPI card (with trend/target/format), chart, gauge, pivot,
88
+ * tree, a rich grid, or a tabbed group of display tiles. */
89
+ export type Tile =
90
+ | { kpi: string; measure?: string; reduce: Reduce; format?: KpiFormat; trendField?: string; trendReduce?: Reduce; target?: number; span?: 1 | 2 | 3 }
91
+ | { chart: string; measure?: string; reduce?: Reduce; type?: ChartType; span?: 1 | 2 | 3 }
92
+ | { gauge: string; measure?: string; reduce: Reduce; min?: number; max?: number; unit?: string; span?: 1 | 2 | 3 }
93
+ | { pivot: { rows: string[]; cols: string[]; measure?: string; aggregate?: Reduce }; span?: 1 | 2 | 3 }
94
+ | { tree: { labelField: string; parentField: string }; span?: 1 | 2 | 3 }
95
+ | ({ grid: true; span?: 1 | 2 | 3 } & GridOpts)
96
+ | { filter: string[]; span?: 1 | 2 | 3 }
97
+ | { tabs: { label: string; tiles: Tile[] }[]; span?: 1 | 2 | 3 }
98
+
99
+ /** The default grid config with enterprise options merged on. */
100
+ function gridConfig(entity: EntitySchema, opts: GridOpts): BlockConfig {
101
+ const base = defaultBlockConfig('grid', entity) as GridConfig
102
+ return {
103
+ ...base,
104
+ ...(opts.format ? { formatRules: opts.format } : {}),
105
+ ...(opts.rowActions ? { rowActions: opts.rowActions } : {}),
106
+ ...(opts.rowLink ? { rowLink: opts.rowLink } : {}),
107
+ ...(opts.summaries ? { rowSummaries: true } : {}),
108
+ ...(opts.density ? { density: opts.density } : {}),
109
+ ...(opts.pageSize ? { pageSize: opts.pageSize } : {}),
110
+ }
111
+ }
112
+
113
+ /** One dashboard tile -> a Block. Recurses for `tabs` (whose children must be
114
+ * display blocks: chart / kpi / gauge / pivot / tree). */
115
+ function tileBlock(entity: EntitySchema, t: Tile, i: number): Block {
116
+ const id = `blk-${i + 1}`
117
+ if ('kpi' in t) {
118
+ const config: BlockConfig = {
119
+ kind: 'kpi', label: t.kpi, ...(t.measure ? { measure: t.measure } : {}), reduce: t.reduce,
120
+ ...(t.format ? { format: t.format } : {}), ...(t.trendField ? { trendField: t.trendField } : {}),
121
+ ...(t.trendReduce ? { trendReduce: t.trendReduce } : {}), ...(t.target != null ? { target: t.target } : {}),
122
+ }
123
+ return { id, span: t.span ?? 1, config }
124
+ }
125
+ if ('chart' in t) {
126
+ return { id, span: t.span ?? 2, config: { kind: 'chart', dimension: t.chart, ...(t.measure ? { measure: t.measure } : {}), reduce: t.reduce ?? 'sum', type: t.type ?? 'bar' } }
127
+ }
128
+ if ('gauge' in t) {
129
+ return { id, span: t.span ?? 1, config: { kind: 'gauge', label: t.gauge, ...(t.measure ? { measure: t.measure } : {}), reduce: t.reduce, min: t.min ?? 0, max: t.max ?? 100, ...(t.unit ? { unit: t.unit } : {}) } }
130
+ }
131
+ if ('pivot' in t) {
132
+ return { id, span: t.span ?? 3, config: { kind: 'pivot', rows: t.pivot.rows, cols: t.pivot.cols, ...(t.pivot.measure ? { measure: t.pivot.measure } : {}), aggregate: t.pivot.aggregate ?? 'sum' } }
133
+ }
134
+ if ('tree' in t) {
135
+ return { id, span: t.span ?? 1, config: { kind: 'tree', labelField: t.tree.labelField, parentField: t.tree.parentField } }
136
+ }
137
+ if ('filter' in t) {
138
+ return { id, span: t.span ?? 3, config: { kind: 'filter', fields: t.filter } }
139
+ }
140
+ if ('tabs' in t) {
141
+ return { id, span: t.span ?? 3, config: { kind: 'tabs', tabs: t.tabs.map((tab) => ({ label: tab.label, blocks: tab.tiles.map((tt, j) => tileBlock(entity, tt, j)) })) } }
142
+ }
143
+ return { id, span: t.span ?? 3, config: gridConfig(entity, t) }
144
+ }
145
+
146
+ /** Compose a dashboard screen from explicit tiles (KPI cards + gauges + charts +
147
+ * pivots + trees + a rich grid). */
148
+ export function dashScreen(
149
+ entity: EntitySchema,
150
+ meta: { id: string; title: string; order: number },
151
+ tiles: Tile[],
152
+ ): Screen {
153
+ const blocks = tiles.map((t, i) => tileBlock(entity, t, i))
154
+ return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
155
+ }
156
+
157
+ /** FormatRule[] that color-code an enum field's cells by its option colors -
158
+ * turns a plain status column into enterprise status pills. */
159
+ export function statusPills(entity: EntitySchema, field: string): FormatRule[] {
160
+ const f = entity.fields.find((x) => x.field === field)
161
+ if (!f?.options) return []
162
+ return f.options
163
+ .filter((o) => o.color)
164
+ .map((o) => ({ field, op: 'eq' as const, value: o.value as string | number, background: `color-mix(in srgb, ${o.color} 18%, transparent)`, color: o.color, bold: true }))
165
+ }
166
+
167
+ /** A list screen: an optional faceted filter panel + a rich grid (status pills,
168
+ * totals, row actions, drill-through). */
169
+ export function listScreen(
170
+ entity: EntitySchema,
171
+ meta: { id: string; title: string; order: number },
172
+ opts: { filter?: string[]; grid?: GridOpts } = {},
173
+ ): Screen {
174
+ const blocks: Block[] = []
175
+ if (opts.filter?.length) blocks.push({ id: 'filter-1', span: 3, config: { kind: 'filter', fields: opts.filter } })
176
+ blocks.push({ id: 'grid-1', span: 3, config: gridConfig(entity, opts.grid ?? {}) })
177
+ return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
178
+ }
179
+
180
+ /**
181
+ * A form-showcase screen: a grid to pick a row + an editable record panel that
182
+ * renders the full field form (phone / rating / tags / mask / slider editors)
183
+ * inline. Lets a user see and try the rich editors on load, not just on edit.
184
+ */
185
+ export function formScreen(
186
+ entity: EntitySchema,
187
+ meta: { id: string; title: string; order: number },
188
+ fields?: string[],
189
+ grid?: GridOpts,
190
+ filter?: string[],
191
+ ): Screen {
192
+ const blocks: Block[] = []
193
+ if (filter?.length) blocks.push({ id: 'filter-1', span: 3, config: { kind: 'filter', fields: filter } })
194
+ blocks.push({ id: 'grid-1', span: 3, config: gridConfig(entity, grid ?? {}) })
195
+ blocks.push({ id: 'record-1', span: 3, config: { kind: 'record', editable: true, ...(fields ? { fields } : {}) } })
196
+ return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
197
+ }
198
+
199
+ /** A Kanban board screen: an optional filter panel + a board grouping rows by an
200
+ * enum field into draggable columns (the signature "pipeline" view). */
201
+ export function boardScreen(
202
+ entity: EntitySchema,
203
+ meta: { id: string; title: string; order: number },
204
+ opts: { groupBy: string; titleField: string; badgeField?: string; subtitleField?: string; filter?: string[]; openScreen?: string },
205
+ ): Screen {
206
+ const blocks: Block[] = []
207
+ if (opts.filter?.length) blocks.push({ id: 'filter-1', span: 3, config: { kind: 'filter', fields: opts.filter } })
208
+ blocks.push({ id: 'board-1', span: 3, config: {
209
+ kind: 'board', groupBy: opts.groupBy, titleField: opts.titleField,
210
+ ...(opts.badgeField ? { badgeField: opts.badgeField } : {}),
211
+ ...(opts.subtitleField ? { subtitleField: opts.subtitleField } : {}),
212
+ ...(opts.openScreen ? { openScreen: opts.openScreen } : {}),
213
+ } })
214
+ return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
215
+ }
216
+
217
+ /** A calendar screen: an optional filter + a month event-calendar placing rows by
218
+ * a date field (the signature "schedule" view). */
219
+ export function calendarScreen(
220
+ entity: EntitySchema,
221
+ meta: { id: string; title: string; order: number },
222
+ opts: { dateField: string; titleField: string; colorField?: string; filter?: string[]; openScreen?: string },
223
+ ): Screen {
224
+ const blocks: Block[] = []
225
+ if (opts.filter?.length) blocks.push({ id: 'filter-1', span: 3, config: { kind: 'filter', fields: opts.filter } })
226
+ blocks.push({ id: 'calendar-1', span: 3, config: {
227
+ kind: 'calendar', dateField: opts.dateField, titleField: opts.titleField,
228
+ ...(opts.colorField ? { colorField: opts.colorField } : {}),
229
+ ...(opts.openScreen ? { openScreen: opts.openScreen } : {}),
230
+ } })
231
+ return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
232
+ }
233
+
234
+ /** A scheduler screen: the grid rendered as a Week / Day / Timeline calendar with optional
235
+ * per-resource columns and drag-to-reschedule. Richer than calendarScreen (start + end,
236
+ * resources, editable write-back) - showcases the grid's scheduler view. */
237
+ export function schedulerScreen(
238
+ entity: EntitySchema,
239
+ meta: { id: string; title: string; order: number },
240
+ opts: { startField: string; endField?: string; titleField?: string; colorField?: string; resourceField?: string; initialView?: SchedulerViewMode; editable?: boolean },
241
+ ): Screen {
242
+ const grid = gridConfig(entity, {}) as GridConfig
243
+ const scheduler: SchedulerViewConfig = {
244
+ startField: opts.startField,
245
+ ...(opts.endField ? { endField: opts.endField } : {}),
246
+ ...(opts.titleField ? { titleField: opts.titleField } : {}),
247
+ ...(opts.colorField ? { colorField: opts.colorField } : {}),
248
+ ...(opts.resourceField ? { resourceField: opts.resourceField } : {}),
249
+ initialView: opts.initialView ?? 'week',
250
+ editable: opts.editable ?? true,
251
+ drawer: true,
252
+ }
253
+ const block: Block = { id: 'grid-1', span: 3, config: { ...grid, scheduler } }
254
+ return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks: [block], nav: { show: true, label: meta.title, order: meta.order } }
255
+ }
256
+
257
+ /** A docking-workspace screen: a filter + grid + record panel arranged as a dockable
258
+ * console (SvDockManager). The smart auto-layout puts filters left, the grid centre, the
259
+ * record panel right; the user can float / pin / rearrange. Showcases screen `layout: 'dock'`. */
260
+ export function workspaceScreen(
261
+ entity: EntitySchema,
262
+ meta: { id: string; title: string; order: number },
263
+ opts: { filter?: string[]; record?: boolean; grid?: GridOpts; mode?: 'dock' | 'split' } = {},
264
+ ): Screen {
265
+ const blocks: Block[] = []
266
+ if (opts.filter?.length) blocks.push({ id: 'filter-1', span: 3, config: { kind: 'filter', fields: opts.filter } })
267
+ blocks.push({ id: 'grid-1', span: 3, config: gridConfig(entity, opts.grid ?? {}) })
268
+ if (opts.record !== false) blocks.push({ id: 'record-1', span: 3, config: { kind: 'record', editable: true } })
269
+ // `split` = fixed resizable panes (a console); `dock` = the full floatable manager.
270
+ const layout = opts.mode ?? 'dock'
271
+ const base: Screen = { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order }, layout }
272
+ return { ...base, dock: buildDockLayout(base) }
273
+ }
274
+
275
+ /** A record detail-page screen: a full record view (header + status pill + metric
276
+ * tiles + tabbed Overview / related timelines) - the signature view for
277
+ * relation-heavy records (a customer / deal / patient / trip page). */
278
+ export function detailScreen(
279
+ entity: EntitySchema,
280
+ meta: { id: string; title: string; order: number },
281
+ opts: { titleField: string; subtitleField?: string; statusField?: string; metricFields?: string[]; sections?: { label: string; fields: string[] }[]; related?: DetailRelated[] },
282
+ ): Screen {
283
+ const blocks: Block[] = [{ id: 'detail-1', span: 3, config: {
284
+ kind: 'detail', titleField: opts.titleField,
285
+ ...(opts.subtitleField ? { subtitleField: opts.subtitleField } : {}),
286
+ ...(opts.statusField ? { statusField: opts.statusField } : {}),
287
+ ...(opts.metricFields?.length ? { metricFields: opts.metricFields } : {}),
288
+ ...(opts.sections?.length ? { sections: opts.sections } : {}),
289
+ ...(opts.related?.length ? { related: opts.related } : {}),
290
+ } }]
291
+ return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
292
+ }
293
+
294
+ /** Build a screen from a template, then override its id / route / title / nav.
295
+ * `linkScreen` wires a master-detail parent row to drill into a detail screen. */
296
+ export function screen(
297
+ entity: EntitySchema,
298
+ template: ScreenTemplate,
299
+ over: { id: string; title: string; route?: string; order: number; child?: EntitySchema; foreignKey?: string; linkScreen?: string },
300
+ ): Screen {
301
+ const base = screenFromTemplate(entity, template, { child: over.child, foreignKey: over.foreignKey })
302
+ const blocks = over.linkScreen
303
+ ? base.blocks.map((b) => (b.config.kind === 'master-detail' ? { ...b, config: { ...b.config, linkScreen: over.linkScreen } } : b))
304
+ : base.blocks
305
+ return {
306
+ ...base,
307
+ blocks,
308
+ id: over.id,
309
+ title: over.title,
310
+ route: over.route ?? over.id,
311
+ nav: { show: true, label: over.title, order: over.order },
312
+ }
313
+ }
314
+
315
+ /** Assemble a sample project: entities + screens + per-entity curated seed + theme/shell. */
316
+ export function project(opts: {
317
+ title: string
318
+ brand: string
319
+ accent: string
320
+ footer?: string
321
+ navStyle?: ShellStyle
322
+ navPosition?: 'left' | 'right'
323
+ /** A design-system preset id (font + radius + palette) from `studioThemes` -
324
+ * what makes each app read as a distinct product (Salesforce vs Excel vs Linear). */
325
+ preset?: string
326
+ mode?: 'light' | 'dark'
327
+ entities: EntitySchema[]
328
+ screens: Screen[]
329
+ seed: Record<string, Record<string, unknown>[]>
330
+ }): StudioProject {
331
+ const dataSources: Record<string, EntityDataSource> = {}
332
+ for (const e of opts.entities) dataSources[e.name] = { kind: 'memory', seed: opts.seed[e.name] ?? [] }
333
+ return sanitizeProject({
334
+ title: opts.title,
335
+ entities: opts.entities,
336
+ screens: opts.screens,
337
+ dataSource: 'memory',
338
+ dataSources,
339
+ theme: {
340
+ accent: opts.accent,
341
+ ...(opts.preset ? { preset: opts.preset } : {}),
342
+ ...(opts.mode ? { mode: opts.mode } : {}),
343
+ shell: { style: opts.navStyle ?? 'sidebar', brand: opts.brand, footer: opts.footer ?? '', navPosition: opts.navPosition ?? 'left' },
344
+ },
345
+ })
346
+ }