@svgrid/enterprise 2.2.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +95 -81
  2. package/dist/cdn/svgrid-enterprise.svelte-external.js +22468 -10459
  3. package/dist/node/studio.js +11590 -2907
  4. package/package.json +31 -10
  5. package/src/SvAlertRuleEditor.svelte +294 -0
  6. package/src/SvAlertsManager.svelte +210 -0
  7. package/src/SvAlertsPanel.svelte +129 -0
  8. package/src/SvBoard.svelte +3 -1
  9. package/src/SvExpressionEditor.svelte +341 -0
  10. package/src/SvGridAlerts.dom.test.ts +113 -0
  11. package/src/SvGridAlerts.svelte +265 -0
  12. package/src/SvGridBoard.svelte +2355 -0
  13. package/src/SvGridEditPanel.svelte +849 -794
  14. package/src/SvGridScheduler.svelte +5334 -4410
  15. package/src/SvPivotDesigner.svelte +3 -3
  16. package/src/SvRecordDetail.svelte +6 -2
  17. package/src/SvSchedule.svelte +3 -1
  18. package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +4 -1
  19. package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +4 -1
  20. package/src/{ai-export.test.ts → ai-export.dom.test.ts} +5 -1
  21. package/src/alerts/alert-engine-attach.ts +165 -0
  22. package/src/alerts/alert-engine.test.ts +135 -0
  23. package/src/alerts/alert-engine.ts +260 -0
  24. package/src/alerts/alert-formats.test.ts +87 -0
  25. package/src/alerts/alert-formats.ts +77 -0
  26. package/src/alerts/alert-observer.test.ts +189 -0
  27. package/src/alerts/alert-observer.ts +208 -0
  28. package/src/alerts/alert-scheduler.ts +96 -0
  29. package/src/alerts/alert-storage.test.ts +54 -0
  30. package/src/alerts/alert-storage.ts +116 -0
  31. package/src/alerts/alert-store.svelte.ts +80 -0
  32. package/src/alerts/alert-types.ts +94 -0
  33. package/src/alerts.ts +28 -0
  34. package/src/board.dom.test.ts +941 -0
  35. package/src/board.ts +36 -0
  36. package/src/export.ts +6 -0
  37. package/src/expressions/evaluate.test.ts +111 -0
  38. package/src/expressions/evaluate.ts +228 -0
  39. package/src/expressions/expression-columns.ts +133 -0
  40. package/src/expressions/expression-types.ts +84 -0
  41. package/src/expressions/parse.test.ts +106 -0
  42. package/src/expressions/parse.ts +610 -0
  43. package/src/import.ts +2 -0
  44. package/src/index.ts +180 -46
  45. package/src/install.ts +12 -2
  46. package/src/pivot-enable.ts +44 -0
  47. package/src/scheduler-assignments.test.ts +97 -0
  48. package/src/scheduler-assignments.ts +134 -0
  49. package/src/scheduler-axis.test.ts +108 -0
  50. package/src/scheduler-axis.ts +238 -0
  51. package/src/scheduler-booking.test.ts +57 -0
  52. package/src/scheduler-booking.ts +63 -0
  53. package/src/scheduler-config.ts +179 -0
  54. package/src/scheduler-dependencies.test.ts +155 -0
  55. package/src/scheduler-dependencies.ts +223 -0
  56. package/src/scheduler-freebusy.test.ts +41 -0
  57. package/src/scheduler-freebusy.ts +36 -0
  58. package/src/scheduler-heatmap.test.ts +39 -0
  59. package/src/scheduler-heatmap.ts +55 -0
  60. package/src/scheduler-resource-tree.test.ts +84 -0
  61. package/src/scheduler-resource-tree.ts +107 -0
  62. package/src/scheduler-slots.test.ts +53 -0
  63. package/src/scheduler-slots.ts +94 -0
  64. package/src/scheduler-summary.test.ts +47 -0
  65. package/src/scheduler-summary.ts +81 -0
  66. package/src/sources/index.ts +1 -1
  67. package/src/sources/introspect-supabase.test.ts +13 -1
  68. package/src/sources/introspect-supabase.ts +20 -0
  69. package/src/studio/copilot-core.test.ts +45 -0
  70. package/src/studio/copilot-core.ts +65 -0
  71. package/src/studio/deploy-cli.test.ts +56 -0
  72. package/src/studio/deploy-cli.ts +100 -0
  73. package/src/studio/emit-project.test.ts +503 -18
  74. package/src/studio/emit-project.ts +829 -111
  75. package/src/studio/emit-schema.test.ts +17 -0
  76. package/src/studio/emit-schema.ts +166 -35
  77. package/src/studio/index.ts +25 -1
  78. package/src/studio/introspect-openapi.test.ts +84 -0
  79. package/src/studio/introspect-openapi.ts +252 -0
  80. package/src/studio/project.test.ts +57 -0
  81. package/src/studio/project.ts +193 -6
  82. package/src/studio/samples/crm.ts +282 -258
  83. package/src/studio/samples/fleet.ts +213 -186
  84. package/src/studio/samples/insurance.ts +223 -195
  85. package/src/studio/samples/inventory.ts +213 -182
  86. package/src/studio/samples/live-data.test.ts +99 -98
  87. package/src/studio/samples/live-data.ts +8 -10
  88. package/src/studio/samples/projects.ts +212 -190
  89. package/src/studio/samples/samples.test.ts +52 -0
  90. package/src/studio/samples/shared.ts +26 -6
  91. package/src/studio/samples/starter.ts +251 -0
  92. package/src/studio/samples/support.ts +209 -184
  93. package/src/studio/ui-components-surface.test.ts +185 -0
  94. package/src/studio/ui-components.generated.ts +5617 -0
  95. package/src/studio/ui-components.ts +641 -472
  96. package/src/sveltekit/sql-source.test.ts +13 -0
  97. package/src/sveltekit/sql-source.ts +11 -6
  98. package/src/upgrade-prompt.ts +2 -2
  99. package/src/watermark.ts +2 -2
  100. package/src/ai.test.ts +0 -522
  101. package/src/ai.ts +0 -1388
@@ -1,190 +1,212 @@
1
- import type { EntitySchema } from '../../schema.js'
2
- import type { FormatRule } from '../project.js'
3
- import { screen, formScreen, boardScreen, schedulerScreen, detailScreen, project, dashScreen, statusPills, pad, ids, type SampleApp } from './shared.js'
4
-
5
- const projectsEntity: EntitySchema = {
6
- name: 'projects',
7
- label: 'Project',
8
- idField: 'id',
9
- fields: [
10
- { field: 'id', type: 'text', primaryKey: true, readonly: true },
11
- { field: 'name', type: 'text', required: true, minLength: 2 },
12
- { field: 'status', type: 'enum', required: true, defaultValue: 'planning', options: [
13
- { value: 'planning', label: 'Planning', color: '#94a3b8' },
14
- { value: 'active', label: 'Active', color: '#6366f1' },
15
- { value: 'on_hold', label: 'On hold', color: '#f59e0b' },
16
- { value: 'done', label: 'Done', color: '#10b981' },
17
- ] },
18
- { field: 'owner', type: 'text', label: 'Owner' },
19
- { field: 'budget', type: 'number', label: 'Budget ($)', min: 0, input: { prefix: '$', help: 'Approved budget' } },
20
- { field: 'progress', type: 'number', label: 'Progress (%)', min: 0, max: 100, defaultValue: 0, input: { editorType: 'slider', help: 'Overall completion' } },
21
- { field: 'color', type: 'text', label: 'Label color', defaultValue: '#6366f1', input: { editorType: 'color', help: 'Board / calendar color' } },
22
- { field: 'tags', type: 'text', label: 'Tags', input: { editorType: 'chips', help: 'Themes / labels' } },
23
- { field: 'dueDate', type: 'date', label: 'Due date', required: true },
24
- ],
25
- }
26
-
27
- const members: EntitySchema = {
28
- name: 'members',
29
- label: 'Member',
30
- idField: 'id',
31
- fields: [
32
- { field: 'id', type: 'text', primaryKey: true, readonly: true },
33
- { field: 'name', type: 'text', required: true, minLength: 2 },
34
- { field: 'email', type: 'text', format: 'email', required: true },
35
- { field: 'phone', type: 'text', label: 'Phone', input: { editorType: 'phone', help: 'Direct line' } },
36
- { field: 'role', type: 'enum', required: true, defaultValue: 'dev', options: [
37
- { value: 'dev', label: 'Engineer', color: '#6366f1' },
38
- { value: 'design', label: 'Designer', color: '#ec4899' },
39
- { value: 'pm', label: 'PM', color: '#f59e0b' },
40
- { value: 'qa', label: 'QA', color: '#10b981' },
41
- ] },
42
- { field: 'skills', type: 'text', label: 'Skills', input: { editorType: 'chips', help: 'Tech / expertise' } },
43
- { field: 'rating', type: 'number', label: 'Performance', min: 1, max: 5, defaultValue: 3, input: { editorType: 'rating', help: '1-5 review score' } },
44
- ],
45
- }
46
-
47
- const tasks: EntitySchema = {
48
- name: 'tasks',
49
- label: 'Task',
50
- idField: 'id',
51
- fields: [
52
- { field: 'id', type: 'text', primaryKey: true, readonly: true },
53
- { field: 'title', type: 'text', required: true, minLength: 2 },
54
- { field: 'projectId', type: 'relation', label: 'Project', relation: { entity: 'projects', foreignKey: 'projectId', labelField: 'name' } },
55
- { field: 'assigneeId', type: 'relation', label: 'Assignee', relation: { entity: 'members', foreignKey: 'assigneeId', labelField: 'name' } },
56
- { field: 'priority', type: 'enum', required: true, defaultValue: 'medium', options: [
57
- { value: 'low', label: 'Low', color: '#94a3b8' },
58
- { value: 'medium', label: 'Medium', color: '#6366f1' },
59
- { value: 'high', label: 'High', color: '#f59e0b' },
60
- { value: 'urgent', label: 'Urgent', color: '#ef4444' },
61
- ] },
62
- { field: 'status', type: 'enum', required: true, defaultValue: 'todo', options: [
63
- { value: 'todo', label: 'To do', color: '#94a3b8' },
64
- { value: 'doing', label: 'In progress', color: '#6366f1' },
65
- { value: 'review', label: 'In review', color: '#f59e0b' },
66
- { value: 'done', label: 'Done', color: '#10b981' },
67
- ] },
68
- { field: 'estimate', type: 'number', label: 'Estimate (h)', min: 0, defaultValue: 0, input: { suffix: 'h', help: 'Estimated hours' } },
69
- { field: 'spent', type: 'number', label: 'Spent (h)', min: 0, defaultValue: 0, input: { suffix: 'h', help: 'Hours logged so far' } },
70
- // Derived: hours left on the task (display-only, never stored).
71
- { field: 'remaining', type: 'number', label: 'Remaining (h)', readonly: true, formula: 'estimate - spent' },
72
- { field: 'progress', type: 'number', label: 'Progress (%)', min: 0, max: 100, defaultValue: 0, input: { editorType: 'slider', help: 'Task completion' } },
73
- { field: 'startedAt', type: 'datetime', label: 'Started', input: { editorType: 'datetime', help: 'When work began' } },
74
- { field: 'dueDate', type: 'date', label: 'Due' },
75
- ],
76
- }
77
-
78
- // Task grid formatting: status + priority pills, plus numeric thresholds -
79
- // over-budget tasks (spent past estimate) red + bold, nearly-done tasks green,
80
- // barely-started tasks red - data-driven color, not just status pills.
81
- const taskFormats: FormatRule[] = [
82
- ...statusPills(tasks, 'status'),
83
- ...statusPills(tasks, 'priority'),
84
- { field: 'spent', op: 'gt', value: 16, color: '#dc2626', bold: true },
85
- { field: 'progress', op: 'gte', value: 80, color: '#16a34a', bold: true },
86
- { field: 'progress', op: 'lt', value: 25, color: '#dc2626' },
87
- ]
88
-
89
- const seed = {
90
- projects: [
91
- { id: 'pr1', name: 'Website redesign', status: 'active', owner: 'Sam Rivera', budget: 48000, progress: 65, color: '#6366f1', tags: ['Marketing', 'Design'], dueDate: '2026-09-01' },
92
- { id: 'pr2', name: 'Mobile app v2', status: 'planning', owner: 'Jamie Chen', budget: 120000, progress: 15, color: '#10b981', tags: ['Mobile', 'Growth'], dueDate: '2026-11-15' },
93
- { id: 'pr3', name: 'Billing migration', status: 'on_hold', owner: 'Priya Patel', budget: 32000, progress: 40, color: '#f59e0b', tags: ['Infra', 'Finance'], dueDate: '2026-08-20' },
94
- { id: 'pr4', name: 'Onboarding revamp', status: 'done', owner: 'Sam Rivera', budget: 18000, progress: 100, color: '#ef4444', tags: ['Product'], dueDate: '2026-06-10' },
95
- ],
96
- members: [
97
- { id: 'm1', name: 'Ada Lovelace', email: 'ada@team.dev', phone: '+1 (415) 555-0101', role: 'dev', skills: ['TypeScript', 'Svelte'], rating: 5 },
98
- { id: 'm2', name: 'Grace Hopper', email: 'grace@team.dev', phone: '+1 (415) 555-0102', role: 'dev', skills: ['Go', 'SQL', 'Docker'], rating: 4 },
99
- { id: 'm3', name: 'Margaret Hamilton', email: 'mh@team.dev', phone: '+1 (415) 555-0103', role: 'pm', skills: ['Product', 'Leadership'], rating: 5 },
100
- { id: 'm4', name: 'Radia Perlman', email: 'radia@team.dev', phone: '+1 (415) 555-0104', role: 'design', skills: ['Figma', 'UX'], rating: 4 },
101
- { id: 'm5', name: 'Katherine Johnson', email: 'kj@team.dev', phone: '+1 (415) 555-0105', role: 'qa', skills: ['Python', 'GraphQL'], rating: 3 },
102
- ],
103
- tasks: [
104
- { id: 't1', title: 'Design new homepage', projectId: 'pr1', assigneeId: 'm4', priority: 'high', status: 'doing', estimate: 16, spent: 6, progress: 40, startedAt: '2026-07-02T09:15:00Z', dueDate: '2026-07-20' },
105
- { id: 't2', title: 'Set up CI pipeline', projectId: 'pr1', assigneeId: 'm1', priority: 'medium', status: 'done', estimate: 8, spent: 8, progress: 100, startedAt: '2026-06-24T10:00:00Z', dueDate: '2026-07-05' },
106
- { id: 't3', title: 'API rate limiting', projectId: 'pr2', assigneeId: 'm2', priority: 'urgent', status: 'todo', estimate: 12, spent: 0, progress: 0, startedAt: '2026-07-18T08:30:00Z', dueDate: '2026-07-25' },
107
- { id: 't4', title: 'Migrate invoices', projectId: 'pr3', assigneeId: 'm1', priority: 'high', status: 'review', estimate: 24, spent: 18, progress: 80, startedAt: '2026-07-06T13:45:00Z', dueDate: '2026-08-01' },
108
- { id: 't5', title: 'QA onboarding flow', projectId: 'pr4', assigneeId: 'm5', priority: 'medium', status: 'done', estimate: 6, spent: 6, progress: 100, startedAt: '2026-06-02T11:20:00Z', dueDate: '2026-06-08' },
109
- { id: 't6', title: 'Push notifications', projectId: 'pr2', assigneeId: 'm2', priority: 'low', status: 'todo', estimate: 10, spent: 0, progress: 0, startedAt: '2026-07-15T09:00:00Z', dueDate: '2026-08-15' },
110
- { id: 't7', title: 'Accessibility audit', projectId: 'pr1', assigneeId: 'm5', priority: 'high', status: 'doing', estimate: 14, spent: 5, progress: 35, startedAt: '2026-07-10T14:00:00Z', dueDate: '2026-07-30' },
111
- { id: 't8', title: 'Pricing page copy', projectId: 'pr1', assigneeId: 'm3', priority: 'low', status: 'review', estimate: 4, spent: 3, progress: 75, startedAt: '2026-07-11T15:30:00Z', dueDate: '2026-07-18' },
112
- ],
113
- }
114
-
115
- export const projectTracker: SampleApp = {
116
- id: 'projects',
117
- name: 'Project tracker',
118
- description: 'Projects, tasks and a team roster - portfolio + task-board dashboards (budget KPI sparkline + target, progress gauge, status/priority breakdowns with pie + area trend, status x priority pivot, tabbed views), filtered status-pill grids with row actions, master/detail, and rich edit forms (progress slider, color labels, tag chips, phone, rating, datetime).',
119
- emoji: '\u{1F4CB}',
120
- accent: '#8b5cf6',
121
- build: () => {
122
- const projectRows = pad(projectsEntity, seed.projects, 10)
123
- const projectPool = ids(projectRows)
124
- const memberRows = pad(members, seed.members, 12)
125
- const memberPool = ids(memberRows)
126
- const taskRows = pad(tasks, seed.tasks, 60, { projectId: projectPool, assigneeId: memberPool })
127
- return project({
128
- title: 'Project Tracker',
129
- brand: 'Tracker',
130
- accent: '#8b5cf6',
131
- preset: 'linear',
132
- footer: '',
133
- entities: [projectsEntity, members, tasks],
134
- seed: { projects: projectRows, members: memberRows, tasks: taskRows },
135
- screens: [
136
- // Portfolio dashboard: budget KPIs (a total-budget card with a sparkline over
137
- // due dates + a $400k target), an average-progress gauge (0-100), status
138
- // breakdowns (bar + pie), a budget-by-owner x status pivot, a tabbed view, and
139
- // a status-pill project grid with inline edit actions.
140
- dashScreen(projectsEntity, { id: 'portfolio', title: 'Portfolio', order: 0 }, [
141
- { kpi: 'Total budget', measure: 'budget', reduce: 'sum', format: 'currency', trendField: 'dueDate', trendReduce: 'sum', target: 400000, span: 1 },
142
- { kpi: 'Projects', reduce: 'count', span: 1 },
143
- { kpi: 'Largest budget', measure: 'budget', reduce: 'max', format: 'currency', span: 1 },
144
- { gauge: 'Avg progress', measure: 'progress', reduce: 'avg', min: 0, max: 100, unit: '%', span: 1 },
145
- { chart: 'status', measure: 'budget', reduce: 'sum', type: 'bar', span: 2 },
146
- { chart: 'status', reduce: 'count', type: 'pie', span: 1 },
147
- { pivot: { rows: ['owner'], cols: ['status'], measure: 'budget', aggregate: 'sum' }, span: 3 },
148
- { tabs: [
149
- { label: 'Budget by status', tiles: [{ chart: 'status', measure: 'budget', reduce: 'sum', type: 'bar' }] },
150
- { label: 'Projects by status', tiles: [{ chart: 'status', reduce: 'count', type: 'pie' }] },
151
- ], span: 3 },
152
- { grid: true, format: [...statusPills(projectsEntity, 'status'), { field: 'progress', op: 'gte', value: 100, color: '#16a34a', bold: true }], summaries: true, rowActions: [{ kind: 'edit' }], span: 3 },
153
- ]),
154
- // Task board: task KPIs (an estimate card trending over due dates), a completion
155
- // gauge, status + priority breakdowns (bar + pie), a spent-over-time area trend,
156
- // an estimate pivot by status (rows) x priority (columns), a tabbed view, a
157
- // filter, and a task grid with status + priority pills and inline edit actions.
158
- // Kanban board: tasks as cards in status columns (drag to change status).
159
- boardScreen(tasks, { id: 'board', title: 'Kanban', order: 1 }, { groupBy: 'status', titleField: 'title', badgeField: 'estimate', subtitleField: 'priority', filter: ['priority', 'assigneeId'] }),
160
- // Project detail: header (status pill) + budget / progress tiles with a
161
- // timeline of the project's tasks (by due date, colored by status).
162
- detailScreen(projectsEntity, { id: 'project-detail', title: 'Project detail', order: 2 }, {
163
- titleField: 'name', subtitleField: 'owner', statusField: 'status', metricFields: ['budget', 'progress'],
164
- related: [{ entity: 'tasks', foreignKey: 'projectId', label: 'Tasks', titleField: 'title', subtitleField: 'priority', dateField: 'dueDate', statusField: 'status' }],
165
- }),
166
- dashScreen(tasks, { id: 'tasks-board', title: 'Task board', order: 3 }, [
167
- { kpi: 'Tasks', reduce: 'count', span: 1 },
168
- { kpi: 'Logged hours', measure: 'spent', reduce: 'sum', format: 'compact', span: 1 },
169
- { kpi: 'Estimated hours', measure: 'estimate', reduce: 'sum', format: 'compact', trendField: 'dueDate', trendReduce: 'sum', span: 1 },
170
- { gauge: 'Avg completion', measure: 'progress', reduce: 'avg', min: 0, max: 100, unit: '%', span: 1 },
171
- { chart: 'status', reduce: 'count', type: 'bar', span: 2 },
172
- { chart: 'priority', reduce: 'count', type: 'pie', span: 1 },
173
- { chart: 'dueDate', measure: 'spent', reduce: 'sum', type: 'area', span: 3 },
174
- { pivot: { rows: ['status'], cols: ['priority'], measure: 'estimate', aggregate: 'sum' }, span: 3 },
175
- { tabs: [
176
- { label: 'Hours by status', tiles: [{ chart: 'status', measure: 'spent', reduce: 'sum', type: 'bar' }] },
177
- { label: 'Tasks by priority', tiles: [{ chart: 'priority', reduce: 'count', type: 'bar' }] },
178
- ], span: 3 },
179
- { filter: ['status', 'priority', 'assigneeId'], span: 3 },
180
- { grid: true, format: taskFormats, summaries: true, rowActions: [{ kind: 'edit' }], span: 3 },
181
- ]),
182
- screen(projectsEntity, 'master-detail', { id: 'projects', title: 'Projects', order: 4, child: tasks, foreignKey: 'projectId', linkScreen: 'project-detail' }),
183
- formScreen(tasks, { id: 'tasks', title: 'Tasks', order: 5 }, undefined, { format: taskFormats, summaries: true, rowActions: [{ kind: 'edit' }] }, ['status', 'priority', 'assigneeId']),
184
- formScreen(members, { id: 'members', title: 'Members', order: 6 }, undefined, { format: statusPills(members, 'role'), summaries: true }, ['role']),
185
- // Schedule: tasks placed on a month calendar by due date.
186
- schedulerScreen(tasks, { id: 'calendar', title: 'Schedule', order: 7 }, { startField: 'startedAt', endField: 'dueDate', titleField: 'title', colorField: 'priority', resourceField: 'assigneeId', initialView: 'timelineWeek' }),
187
- ],
188
- })
189
- },
190
- }
1
+ import type { EntitySchema } from '../../schema.js'
2
+ import type { FormatRule } from '../project.js'
3
+ import { screen, formScreen, boardScreen, schedulerScreen, detailScreen, project, dashScreen, statusPills, pad, ids, type SampleApp } from './shared.js'
4
+
5
+ // Jira-style chrome: a dark navy issue-tracker sidebar with muted labels and a
6
+ // blue active row (accent bar + tinted fill), the board front and centre.
7
+ const JIRA_CSS = `.sv-app.theme-jira .sv-app__side { background: #1d2125; border-right: 1px solid #2c333a; }
8
+ .sv-app.theme-jira .sv-app__brandtext { color: #fff; font-weight: 700; }
9
+ .sv-app.theme-jira .sv-app__link { color: #c7d1db; border-radius: 4px; padding: 7px 12px; }
10
+ .sv-app.theme-jira .sv-app__link:hover { background: rgba(255, 255, 255, 0.08); color: #fff; }
11
+ .sv-app.theme-jira .sv-app__link.is-active { background: color-mix(in srgb, #0c66e4 30%, #1d2125); color: #fff; box-shadow: inset 3px 0 0 #4c9aff; }
12
+ .sv-app.theme-jira .sv-app__collapse, .sv-app.theme-jira .sv-app__foot { color: #8c9bab; }`
13
+
14
+ const projectsEntity: EntitySchema = {
15
+ name: 'projects',
16
+ label: 'Project',
17
+ idField: 'id',
18
+ fields: [
19
+ { field: 'id', type: 'text', primaryKey: true, readonly: true },
20
+ { field: 'name', type: 'text', required: true, minLength: 2 },
21
+ { field: 'status', type: 'enum', required: true, defaultValue: 'planning', options: [
22
+ { value: 'planning', label: 'Planning', color: '#94a3b8' },
23
+ { value: 'active', label: 'Active', color: '#6366f1' },
24
+ { value: 'on_hold', label: 'On hold', color: '#f59e0b' },
25
+ { value: 'done', label: 'Done', color: '#10b981' },
26
+ ] },
27
+ { field: 'owner', type: 'text', label: 'Owner' },
28
+ { field: 'budget', type: 'number', label: 'Budget ($)', min: 0, input: { prefix: '$', help: 'Approved budget' } },
29
+ { field: 'progress', type: 'number', label: 'Progress (%)', min: 0, max: 100, defaultValue: 0, input: { editorType: 'slider', help: 'Overall completion' } },
30
+ { field: 'color', type: 'text', label: 'Label color', defaultValue: '#6366f1', input: { editorType: 'color', help: 'Board / calendar color' } },
31
+ { field: 'tags', type: 'text', label: 'Tags', input: { editorType: 'chips', help: 'Themes / labels' } },
32
+ { field: 'dueDate', type: 'date', label: 'Due date', required: true },
33
+ ],
34
+ }
35
+
36
+ const members: EntitySchema = {
37
+ name: 'members',
38
+ label: 'Member',
39
+ idField: 'id',
40
+ fields: [
41
+ { field: 'id', type: 'text', primaryKey: true, readonly: true },
42
+ { field: 'name', type: 'text', required: true, minLength: 2 },
43
+ { field: 'email', type: 'text', format: 'email', required: true },
44
+ { field: 'phone', type: 'text', label: 'Phone', input: { editorType: 'phone', help: 'Direct line' } },
45
+ { field: 'role', type: 'enum', required: true, defaultValue: 'dev', options: [
46
+ { value: 'dev', label: 'Engineer', color: '#6366f1' },
47
+ { value: 'design', label: 'Designer', color: '#ec4899' },
48
+ { value: 'pm', label: 'PM', color: '#f59e0b' },
49
+ { value: 'qa', label: 'QA', color: '#10b981' },
50
+ ] },
51
+ { field: 'skills', type: 'text', label: 'Skills', input: { editorType: 'chips', help: 'Tech / expertise' } },
52
+ { field: 'rating', type: 'number', label: 'Performance', min: 1, max: 5, defaultValue: 3, input: { editorType: 'rating', help: '1-5 review score' } },
53
+ ],
54
+ }
55
+
56
+ const tasks: EntitySchema = {
57
+ name: 'tasks',
58
+ label: 'Task',
59
+ idField: 'id',
60
+ fields: [
61
+ { field: 'id', type: 'text', primaryKey: true, readonly: true },
62
+ { field: 'title', type: 'text', required: true, minLength: 2 },
63
+ { field: 'projectId', type: 'relation', label: 'Project', relation: { entity: 'projects', foreignKey: 'projectId', labelField: 'name' } },
64
+ { field: 'assigneeId', type: 'relation', label: 'Assignee', relation: { entity: 'members', foreignKey: 'assigneeId', labelField: 'name' } },
65
+ { field: 'priority', type: 'enum', required: true, defaultValue: 'medium', options: [
66
+ { value: 'low', label: 'Low', color: '#94a3b8' },
67
+ { value: 'medium', label: 'Medium', color: '#6366f1' },
68
+ { value: 'high', label: 'High', color: '#f59e0b' },
69
+ { value: 'urgent', label: 'Urgent', color: '#ef4444' },
70
+ ] },
71
+ { field: 'status', type: 'enum', required: true, defaultValue: 'todo', options: [
72
+ { value: 'todo', label: 'To do', color: '#94a3b8' },
73
+ { value: 'doing', label: 'In progress', color: '#6366f1' },
74
+ { value: 'review', label: 'In review', color: '#f59e0b' },
75
+ { value: 'done', label: 'Done', color: '#10b981' },
76
+ ] },
77
+ { field: 'estimate', type: 'number', label: 'Estimate (h)', min: 0, defaultValue: 0, input: { suffix: 'h', help: 'Estimated hours' } },
78
+ { field: 'spent', type: 'number', label: 'Spent (h)', min: 0, defaultValue: 0, input: { suffix: 'h', help: 'Hours logged so far' } },
79
+ // Derived: hours left on the task (display-only, never stored).
80
+ { field: 'remaining', type: 'number', label: 'Remaining (h)', readonly: true, formula: 'estimate - spent' },
81
+ { field: 'progress', type: 'number', label: 'Progress (%)', min: 0, max: 100, defaultValue: 0, input: { editorType: 'slider', help: 'Task completion' } },
82
+ { field: 'startedAt', type: 'datetime', label: 'Started', input: { editorType: 'datetime', help: 'When work began' } },
83
+ { field: 'dueDate', type: 'date', label: 'Due' },
84
+ ],
85
+ }
86
+
87
+ // Task grid formatting: status + priority pills, plus numeric thresholds -
88
+ // over-budget tasks (spent past estimate) red + bold, nearly-done tasks green,
89
+ // barely-started tasks red - data-driven color, not just status pills.
90
+ const taskFormats: FormatRule[] = [
91
+ ...statusPills(tasks, 'status'),
92
+ ...statusPills(tasks, 'priority'),
93
+ { field: 'spent', op: 'gt', value: 16, color: '#dc2626', bold: true },
94
+ { field: 'progress', op: 'gte', value: 80, color: '#16a34a', bold: true },
95
+ { field: 'progress', op: 'lt', value: 25, color: '#dc2626' },
96
+ ]
97
+
98
+ const seed = {
99
+ projects: [
100
+ { id: 'pr1', name: 'Website redesign', status: 'active', owner: 'Sam Rivera', budget: 48000, progress: 65, color: '#6366f1', tags: ['Marketing', 'Design'], dueDate: '2026-09-01' },
101
+ { id: 'pr2', name: 'Mobile app v2', status: 'planning', owner: 'Jamie Chen', budget: 120000, progress: 15, color: '#10b981', tags: ['Mobile', 'Growth'], dueDate: '2026-11-15' },
102
+ { id: 'pr3', name: 'Billing migration', status: 'on_hold', owner: 'Priya Patel', budget: 32000, progress: 40, color: '#f59e0b', tags: ['Infra', 'Finance'], dueDate: '2026-08-20' },
103
+ { id: 'pr4', name: 'Onboarding revamp', status: 'done', owner: 'Sam Rivera', budget: 18000, progress: 100, color: '#ef4444', tags: ['Product'], dueDate: '2026-06-10' },
104
+ ],
105
+ members: [
106
+ { id: 'm1', name: 'Ada Lovelace', email: 'ada@team.dev', phone: '+1 (415) 555-0101', role: 'dev', skills: ['TypeScript', 'Svelte'], rating: 5 },
107
+ { id: 'm2', name: 'Grace Hopper', email: 'grace@team.dev', phone: '+1 (415) 555-0102', role: 'dev', skills: ['Go', 'SQL', 'Docker'], rating: 4 },
108
+ { id: 'm3', name: 'Margaret Hamilton', email: 'mh@team.dev', phone: '+1 (415) 555-0103', role: 'pm', skills: ['Product', 'Leadership'], rating: 5 },
109
+ { id: 'm4', name: 'Radia Perlman', email: 'radia@team.dev', phone: '+1 (415) 555-0104', role: 'design', skills: ['Figma', 'UX'], rating: 4 },
110
+ { id: 'm5', name: 'Katherine Johnson', email: 'kj@team.dev', phone: '+1 (415) 555-0105', role: 'qa', skills: ['Python', 'GraphQL'], rating: 3 },
111
+ ],
112
+ tasks: [
113
+ { id: 't1', title: 'Design new homepage', projectId: 'pr1', assigneeId: 'm4', priority: 'high', status: 'doing', estimate: 16, spent: 6, progress: 40, startedAt: '2026-07-02T09:15:00Z', dueDate: '2026-07-20' },
114
+ { id: 't2', title: 'Set up CI pipeline', projectId: 'pr1', assigneeId: 'm1', priority: 'medium', status: 'done', estimate: 8, spent: 8, progress: 100, startedAt: '2026-06-24T10:00:00Z', dueDate: '2026-07-05' },
115
+ { id: 't3', title: 'API rate limiting', projectId: 'pr2', assigneeId: 'm2', priority: 'urgent', status: 'todo', estimate: 12, spent: 0, progress: 0, startedAt: '2026-07-18T08:30:00Z', dueDate: '2026-07-25' },
116
+ { id: 't4', title: 'Migrate invoices', projectId: 'pr3', assigneeId: 'm1', priority: 'high', status: 'review', estimate: 24, spent: 18, progress: 80, startedAt: '2026-07-06T13:45:00Z', dueDate: '2026-08-01' },
117
+ { id: 't5', title: 'QA onboarding flow', projectId: 'pr4', assigneeId: 'm5', priority: 'medium', status: 'done', estimate: 6, spent: 6, progress: 100, startedAt: '2026-06-02T11:20:00Z', dueDate: '2026-06-08' },
118
+ { id: 't6', title: 'Push notifications', projectId: 'pr2', assigneeId: 'm2', priority: 'low', status: 'todo', estimate: 10, spent: 0, progress: 0, startedAt: '2026-07-15T09:00:00Z', dueDate: '2026-08-15' },
119
+ { id: 't7', title: 'Accessibility audit', projectId: 'pr1', assigneeId: 'm5', priority: 'high', status: 'doing', estimate: 14, spent: 5, progress: 35, startedAt: '2026-07-10T14:00:00Z', dueDate: '2026-07-30' },
120
+ { id: 't8', title: 'Pricing page copy', projectId: 'pr1', assigneeId: 'm3', priority: 'low', status: 'review', estimate: 4, spent: 3, progress: 75, startedAt: '2026-07-11T15:30:00Z', dueDate: '2026-07-18' },
121
+ ],
122
+ }
123
+
124
+ export const projectTracker: SampleApp = {
125
+ id: 'projects',
126
+ name: 'Project tracker',
127
+ description: 'Projects, tasks and a team roster - a Jira-style issue tracker behind a sign-in, with portfolio + task-board dashboards (budget KPI sparkline + target, progress gauge, status/priority breakdowns with pie + area trend, status x priority pivot, tabbed views), filtered status-pill grids with row actions, master/detail, and rich edit forms (progress slider, color labels, tag chips, phone, rating, datetime).',
128
+ emoji: '\u{1F4CB}',
129
+ accent: '#0c66e4',
130
+ build: () => {
131
+ const projectRows = pad(projectsEntity, seed.projects, 10)
132
+ const projectPool = ids(projectRows)
133
+ const memberRows = pad(members, seed.members, 12)
134
+ const memberPool = ids(memberRows)
135
+ const taskRows = pad(tasks, seed.tasks, 60, { projectId: projectPool, assigneeId: memberPool })
136
+ return project({
137
+ title: 'Project Tracker',
138
+ brand: 'Tracker',
139
+ accent: '#0c66e4',
140
+ preset: 'linear',
141
+ footer: '',
142
+ // Jira-style issue tracker: dark navy rail, board-forward.
143
+ appClass: 'theme-jira',
144
+ customCss: JIRA_CSS,
145
+ // Sign-in with two roles: developers move issues but cannot delete them.
146
+ auth: { enabled: true, protect: true },
147
+ access: {
148
+ enabled: true,
149
+ defaultRole: 'developer',
150
+ roles: [
151
+ { role: 'admin', screens: '*', actions: '*' },
152
+ { role: 'developer', screens: '*', actions: ['create', 'update'] },
153
+ ],
154
+ },
155
+ entities: [projectsEntity, members, tasks],
156
+ seed: { projects: projectRows, members: memberRows, tasks: taskRows },
157
+ screens: [
158
+ // Portfolio dashboard: budget KPIs (a total-budget card with a sparkline over
159
+ // due dates + a $400k target), an average-progress gauge (0-100), status
160
+ // breakdowns (bar + pie), a budget-by-owner x status pivot, a tabbed view, and
161
+ // a status-pill project grid with inline edit actions.
162
+ dashScreen(projectsEntity, { id: 'portfolio', title: 'Portfolio', order: 0 }, [
163
+ { kpi: 'Total budget', measure: 'budget', reduce: 'sum', format: 'currency', trendField: 'dueDate', trendReduce: 'sum', target: 400000, span: 1 },
164
+ { kpi: 'Projects', reduce: 'count', span: 1 },
165
+ { kpi: 'Largest budget', measure: 'budget', reduce: 'max', format: 'currency', span: 1 },
166
+ { gauge: 'Avg progress', measure: 'progress', reduce: 'avg', min: 0, max: 100, unit: '%', span: 1 },
167
+ { chart: 'status', measure: 'budget', reduce: 'sum', type: 'bar', span: 2 },
168
+ { chart: 'status', reduce: 'count', type: 'pie', span: 1 },
169
+ { pivot: { rows: ['owner'], cols: ['status'], measure: 'budget', aggregate: 'sum' }, span: 3 },
170
+ { tabs: [
171
+ { label: 'Budget by status', tiles: [{ chart: 'status', measure: 'budget', reduce: 'sum', type: 'bar' }] },
172
+ { label: 'Projects by status', tiles: [{ chart: 'status', reduce: 'count', type: 'pie' }] },
173
+ ], span: 3 },
174
+ { grid: true, format: [...statusPills(projectsEntity, 'status'), { field: 'progress', op: 'gte', value: 100, color: '#16a34a', bold: true }], summaries: true, rowActions: [{ kind: 'edit' }], span: 3 },
175
+ ]),
176
+ // Task board: task KPIs (an estimate card trending over due dates), a completion
177
+ // gauge, status + priority breakdowns (bar + pie), a spent-over-time area trend,
178
+ // an estimate pivot by status (rows) x priority (columns), a tabbed view, a
179
+ // filter, and a task grid with status + priority pills and inline edit actions.
180
+ // Kanban board: tasks as cards in status columns (drag to change status).
181
+ boardScreen(tasks, { id: 'board', title: 'Kanban', order: 1 }, { groupBy: 'status', titleField: 'title', badgeField: 'estimate', subtitleField: 'priority', filter: ['priority', 'assigneeId'] }),
182
+ // Project detail: header (status pill) + budget / progress tiles with a
183
+ // timeline of the project's tasks (by due date, colored by status).
184
+ detailScreen(projectsEntity, { id: 'project-detail', title: 'Project detail', order: 2 }, {
185
+ titleField: 'name', subtitleField: 'owner', statusField: 'status', metricFields: ['budget', 'progress'],
186
+ related: [{ entity: 'tasks', foreignKey: 'projectId', label: 'Tasks', titleField: 'title', subtitleField: 'priority', dateField: 'dueDate', statusField: 'status' }],
187
+ }),
188
+ dashScreen(tasks, { id: 'tasks-board', title: 'Task board', order: 3 }, [
189
+ { kpi: 'Tasks', reduce: 'count', span: 1 },
190
+ { kpi: 'Logged hours', measure: 'spent', reduce: 'sum', format: 'compact', span: 1 },
191
+ { kpi: 'Estimated hours', measure: 'estimate', reduce: 'sum', format: 'compact', trendField: 'dueDate', trendReduce: 'sum', span: 1 },
192
+ { gauge: 'Avg completion', measure: 'progress', reduce: 'avg', min: 0, max: 100, unit: '%', span: 1 },
193
+ { chart: 'status', reduce: 'count', type: 'bar', span: 2 },
194
+ { chart: 'priority', reduce: 'count', type: 'pie', span: 1 },
195
+ { chart: 'dueDate', measure: 'spent', reduce: 'sum', type: 'area', span: 3 },
196
+ { pivot: { rows: ['status'], cols: ['priority'], measure: 'estimate', aggregate: 'sum' }, span: 3 },
197
+ { tabs: [
198
+ { label: 'Hours by status', tiles: [{ chart: 'status', measure: 'spent', reduce: 'sum', type: 'bar' }] },
199
+ { label: 'Tasks by priority', tiles: [{ chart: 'priority', reduce: 'count', type: 'bar' }] },
200
+ ], span: 3 },
201
+ { filter: ['status', 'priority', 'assigneeId'], span: 3 },
202
+ { grid: true, format: taskFormats, summaries: true, rowActions: [{ kind: 'edit' }], span: 3 },
203
+ ]),
204
+ screen(projectsEntity, 'master-detail', { id: 'projects', title: 'Projects', order: 4, child: tasks, foreignKey: 'projectId', linkScreen: 'project-detail' }),
205
+ formScreen(tasks, { id: 'tasks', title: 'Tasks', order: 5 }, undefined, { format: taskFormats, summaries: true, rowActions: [{ kind: 'edit' }] }, ['status', 'priority', 'assigneeId']),
206
+ formScreen(members, { id: 'members', title: 'Members', order: 6 }, undefined, { format: statusPills(members, 'role'), summaries: true }, ['role']),
207
+ // Schedule: tasks placed on a month calendar by due date.
208
+ schedulerScreen(tasks, { id: 'calendar', title: 'Schedule', order: 7 }, { startField: 'startedAt', endField: 'dueDate', titleField: 'title', colorField: 'priority', resourceField: 'assigneeId', initialView: 'timelineWeek' }),
209
+ ],
210
+ })
211
+ },
212
+ }
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import { compile } from 'svelte/compiler'
3
3
  import { sampleApps, getSampleApp } from './index.js'
4
+ import { starterProject } from './starter.js'
4
5
  import { validateProject, parseProject, serializeProject } from '../project.js'
5
6
  import { emitStudioProject } from '../emit-project.js'
6
7
 
@@ -214,3 +215,54 @@ describe('sample apps', () => {
214
215
  })
215
216
  })
216
217
  })
218
+
219
+ describe('starter project (fresh `svgrid-studio designer` session)', () => {
220
+ const project = starterProject()
221
+
222
+ it('is a valid, multi-page CRM (dashboard + CRUD + board + calendar + forecast) with seeded rows', () => {
223
+ expect(validateProject(project).filter((i) => i.level === 'error')).toHaveLength(0)
224
+ expect(project.entities.map((e) => e.name)).toEqual(['customers', 'deals'])
225
+ // Several pages, so the first-run app is a real app, not a lone table.
226
+ const ids = project.screens.map((s) => s.id)
227
+ expect(ids).toEqual(['overview', 'customers', 'pipeline', 'deals', 'calendar', 'forecast'])
228
+ // A range of block kinds across the app (the "wow" surface).
229
+ const kinds = new Set(project.screens.flatMap((s) => s.blocks.map((b) => b.config.kind)))
230
+ for (const k of ['kpi', 'chart', 'pivot', 'grid', 'board', 'calendar']) expect(kinds.has(k as never), `missing ${k}`).toBe(true)
231
+ for (const e of ['customers', 'deals']) {
232
+ const seed = (project.dataSources?.[e] as { seed?: unknown[] } | undefined)?.seed
233
+ expect(seed?.length ?? 0, `${e} seed`).toBeGreaterThanOrEqual(10)
234
+ }
235
+ })
236
+
237
+ it('the Customers page turns on the enterprise-grade grid surface (filter + export + pills + row actions)', () => {
238
+ const screen = project.screens.find((s) => s.id === 'customers')!
239
+ const grid = screen.blocks.find((b) => b.config.kind === 'grid')!.config as Record<string, unknown>
240
+ expect(screen.blocks.some((b) => b.config.kind === 'filter')).toBe(true)
241
+ expect(grid.filterable).toBe(true)
242
+ expect(grid.export).toEqual({ csv: true, json: true, copy: true })
243
+ expect(grid.rowSummaries).toBe(true)
244
+ expect((grid.rowActions as unknown[])?.length).toBeGreaterThan(0)
245
+ // status / industry / plan render as colored chips (editorType 'list' on the enum fields).
246
+ const cust = project.entities.find((e) => e.name === 'customers')!
247
+ for (const f of ['status', 'industry', 'plan']) {
248
+ expect(cust.fields.find((x) => x.field === f)?.input?.editorType, `${f} chip`).toBe('list')
249
+ }
250
+ })
251
+
252
+ it('generates enterprise-wired, compiling code with a visible export toolbar', () => {
253
+ const files = emitStudioProject(project)
254
+ const customersPage = files.find((f) => f.path.endsWith('customers/+page.svelte'))!.contents
255
+ expect(customersPage).toContain('@svgrid/enterprise')
256
+ expect(customersPage).toContain('exportCsv') // export toolbar wired to the grid api
257
+ // Every generated screen compiles (dashboard, board, calendar, forecast, ...).
258
+ for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
259
+ expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
260
+ }
261
+ })
262
+
263
+ it('round-trips through parse/serialize', () => {
264
+ const again = parseProject(serializeProject(project))
265
+ expect(again.entities.map((e) => e.name)).toEqual(project.entities.map((e) => e.name))
266
+ expect(again.dataSources).toEqual(project.dataSources)
267
+ })
268
+ })
@@ -10,7 +10,7 @@
10
10
  import type { EntitySchema } from '../../schema.js'
11
11
  import type { ChartType } from '@svgrid/grid'
12
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'
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, type AuthConfig, type AccessControl } from '../project.js'
14
14
 
15
15
  type Row = Record<string, unknown>
16
16
 
@@ -111,9 +111,12 @@ function gridConfig(entity: EntitySchema, opts: GridOpts): BlockConfig {
111
111
  }
112
112
 
113
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}`
114
+ * display blocks: chart / kpi / gauge / pivot / tree). `nextId` yields ids unique
115
+ * across the WHOLE screen (top-level tiles AND every nested tab child) - a per-level
116
+ * index would collide (top-level `blk-1` vs each tab's first child `blk-1`), which
117
+ * makes a keyed each throw `each_key_duplicate`. */
118
+ function tileBlock(entity: EntitySchema, t: Tile, nextId: () => string): Block {
119
+ const id = nextId()
117
120
  if ('kpi' in t) {
118
121
  const config: BlockConfig = {
119
122
  kind: 'kpi', label: t.kpi, ...(t.measure ? { measure: t.measure } : {}), reduce: t.reduce,
@@ -138,7 +141,7 @@ function tileBlock(entity: EntitySchema, t: Tile, i: number): Block {
138
141
  return { id, span: t.span ?? 3, config: { kind: 'filter', fields: t.filter } }
139
142
  }
140
143
  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)) })) } }
144
+ return { id, span: t.span ?? 3, config: { kind: 'tabs', tabs: t.tabs.map((tab) => ({ label: tab.label, blocks: tab.tiles.map((tt) => tileBlock(entity, tt, nextId)) })) } }
142
145
  }
143
146
  return { id, span: t.span ?? 3, config: gridConfig(entity, t) }
144
147
  }
@@ -150,7 +153,9 @@ export function dashScreen(
150
153
  meta: { id: string; title: string; order: number },
151
154
  tiles: Tile[],
152
155
  ): Screen {
153
- const blocks = tiles.map((t, i) => tileBlock(entity, t, i))
156
+ let n = 0
157
+ const nextId = () => `blk-${++n}`
158
+ const blocks = tiles.map((t) => tileBlock(entity, t, nextId))
154
159
  return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
155
160
  }
156
161
 
@@ -324,6 +329,17 @@ export function project(opts: {
324
329
  * what makes each app read as a distinct product (Salesforce vs Excel vs Linear). */
325
330
  preset?: string
326
331
  mode?: 'light' | 'dark'
332
+ /** Bespoke app chrome: raw CSS written to the generated app's `custom.css` (imported
333
+ * after `app.css`, so it overrides). Scope rules under `.sv-app.<appClass>` so an
334
+ * app can look like its real-world archetype, not a recolored sibling. */
335
+ customCss?: string
336
+ /** Extra class on the shell root (the hook `customCss` targets). */
337
+ appClass?: string
338
+ /** A sign-in starter: `{ enabled: true }` gives the generated app a `/login` page,
339
+ * a signed-cookie session, and route guards (dependency-free, no DB). */
340
+ auth?: AuthConfig
341
+ /** Role-based access. With `auth`, seeds one demo login per role. */
342
+ access?: AccessControl
327
343
  entities: EntitySchema[]
328
344
  screens: Screen[]
329
345
  seed: Record<string, Record<string, unknown>[]>
@@ -340,7 +356,11 @@ export function project(opts: {
340
356
  accent: opts.accent,
341
357
  ...(opts.preset ? { preset: opts.preset } : {}),
342
358
  ...(opts.mode ? { mode: opts.mode } : {}),
359
+ ...(opts.customCss ? { customCss: opts.customCss } : {}),
360
+ ...(opts.appClass ? { appClass: opts.appClass } : {}),
343
361
  shell: { style: opts.navStyle ?? 'sidebar', brand: opts.brand, footer: opts.footer ?? '', navPosition: opts.navPosition ?? 'left' },
344
362
  },
363
+ ...(opts.auth?.enabled ? { auth: opts.auth } : {}),
364
+ ...(opts.access?.enabled ? { access: opts.access } : {}),
345
365
  })
346
366
  }