@svgrid/enterprise 2.0.4 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +18 -6
  2. package/dist/cdn/svgrid-enterprise.svelte-external.js +14035 -6842
  3. package/dist/node/studio.js +7888 -2459
  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/export.ts +7 -1
  12. package/src/index.ts +409 -384
  13. package/src/install.ts +10 -0
  14. package/src/pivot-chart.test.ts +86 -0
  15. package/src/pivot-chart.ts +112 -0
  16. package/src/scheduler.ts +37 -0
  17. package/src/scheduling.test.ts +194 -0
  18. package/src/scheduling.ts +293 -0
  19. package/src/sources/filters.ts +6 -0
  20. package/src/studio/HANDLERS-DESIGN.md +142 -0
  21. package/src/studio/cli.ts +7 -2
  22. package/src/studio/emit-project.test.ts +1447 -13
  23. package/src/studio/emit-project.ts +3995 -1273
  24. package/src/studio/emit-schema.ts +146 -29
  25. package/src/studio/index.ts +320 -195
  26. package/src/studio/project.test.ts +370 -0
  27. package/src/studio/project.ts +1146 -26
  28. package/src/studio/sample-data.ts +4 -1
  29. package/src/studio/samples/ats.ts +2 -2
  30. package/src/studio/samples/clinic.ts +4 -2
  31. package/src/studio/samples/crm.ts +16 -8
  32. package/src/studio/samples/events.ts +4 -2
  33. package/src/studio/samples/fleet.ts +4 -2
  34. package/src/studio/samples/gym.ts +4 -2
  35. package/src/studio/samples/hr.ts +3 -1
  36. package/src/studio/samples/live-data.ts +308 -308
  37. package/src/studio/samples/projects.ts +2 -2
  38. package/src/studio/samples/restaurant.ts +4 -2
  39. package/src/studio/samples/samples.test.ts +13 -5
  40. package/src/studio/samples/shared.ts +346 -305
  41. package/src/studio/samples/support.ts +3 -1
  42. package/src/studio/scaffold.test.ts +15 -1
  43. package/src/studio/scaffold.ts +16 -0
  44. package/src/studio/themes.ts +7 -0
  45. package/src/studio/ui-components.ts +472 -0
  46. package/src/sveltekit/transport.test.ts +26 -0
  47. package/src/sveltekit/transport.ts +50 -5
  48. package/dist/designer/assets/index-Dp44bTid.js +0 -939
  49. package/dist/designer/assets/index-RJp6x8tw.css +0 -1
  50. package/dist/designer/assets/jszip.min-CjMo-QGg.js +0 -2
  51. package/dist/designer/index.html +0 -13
@@ -1,5 +1,5 @@
1
1
  import type { EntitySchema } from '../../schema.js'
2
- import { screen, formScreen, boardScreen, detailScreen, project, dashScreen, statusPills, pad, ids, type SampleApp } from './shared.js'
2
+ import { screen, formScreen, boardScreen, workspaceScreen, detailScreen, project, dashScreen, statusPills, pad, ids, type SampleApp } from './shared.js'
3
3
 
4
4
  const customers: EntitySchema = {
5
5
  name: 'customers',
@@ -148,6 +148,8 @@ export const support: SampleApp = {
148
148
  // priority (rows) x status (columns).
149
149
  // Ticket board: tickets as cards in status columns (drag to change status).
150
150
  boardScreen(tickets, { id: 'board', title: 'Board', order: 1 }, { groupBy: 'status', titleField: 'subject', subtitleField: 'priority', filter: ['priority', 'channel', 'agentId'] }),
151
+ // A docking console: filter | ticket queue | selected-ticket panel (drag / float / pin).
152
+ workspaceScreen(tickets, { id: 'console', title: 'Console', order: 2 }, { filter: ['priority', 'channel', 'agentId'], record: true, grid: { format: [...statusPills(tickets, 'status'), ...statusPills(tickets, 'priority')] } }),
151
153
  // Customer 360: account header (plan pill + health tile) with a timeline of
152
154
  // the customer's tickets (newest first, colored by status).
153
155
  detailScreen(customers, { id: 'customer-360', title: 'Customer 360', order: 2 }, {
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { compile } from 'svelte/compiler'
3
3
  import type { EntitySchema } from '../schema'
4
- import { MANAGED_END, MANAGED_START, mergeManaged, scaffold } from './scaffold'
4
+ import { MANAGED_END, MANAGED_START, mergeManaged, scaffold, skipUserOwned } from './scaffold'
5
5
 
6
6
  const schema: EntitySchema = {
7
7
  name: 'customers',
@@ -176,3 +176,17 @@ describe('mergeManaged', () => {
176
176
  expect(mergeManaged('no markers here', 'GEN')).toBe('GEN')
177
177
  })
178
178
  })
179
+
180
+ describe('skipUserOwned (the handlers.ts write-once contract)', () => {
181
+ it('skips a user-owned file that already exists on disk (never clobber user code)', () => {
182
+ expect(skipUserOwned({ userOwned: true }, true)).toBe(true)
183
+ })
184
+ it('writes a user-owned file when it does not exist yet (scaffold the stub once)', () => {
185
+ expect(skipUserOwned({ userOwned: true }, false)).toBe(false)
186
+ })
187
+ it('always writes a generated (non-user-owned) file, whether or not it exists', () => {
188
+ expect(skipUserOwned({ userOwned: false }, true)).toBe(false)
189
+ expect(skipUserOwned({}, true)).toBe(false)
190
+ expect(skipUserOwned({ userOwned: undefined }, true)).toBe(false)
191
+ })
192
+ })
@@ -20,6 +20,10 @@ export type GeneratedFile = {
20
20
  path: string
21
21
  contents: string
22
22
  description: string
23
+ /** A user-owned companion (e.g. a screen's `handlers.ts`): scaffolded once as a
24
+ * starting stub, then never overwritten. In-place writers must skip it if it
25
+ * already exists. See HANDLERS-DESIGN.md. */
26
+ userOwned?: boolean
23
27
  }
24
28
 
25
29
  export type ScaffoldOptions = {
@@ -68,6 +72,18 @@ const DRIVERS: Record<'postgres' | 'mysql' | 'mssql' | 'sqlite', DriverWiring> =
68
72
  },
69
73
  }
70
74
 
75
+ /**
76
+ * Whether an in-place writer must SKIP this file to preserve the developer's
77
+ * code: a user-owned companion (a screen's `handlers.ts`) that already exists on
78
+ * disk. Such files carry no `svgrid:managed` markers, so `mergeManaged` would
79
+ * overwrite them wholesale - the write-once contract lives here instead, shared
80
+ * by every writer (CLI regenerate + the designer's bundle write). See
81
+ * HANDLERS-DESIGN.md.
82
+ */
83
+ export function skipUserOwned(file: Pick<GeneratedFile, 'userOwned'>, existsOnDisk: boolean): boolean {
84
+ return file.userOwned === true && existsOnDisk
85
+ }
86
+
71
87
  export const MANAGED_START = '// svgrid:managed:start'
72
88
  export const MANAGED_END = '// svgrid:managed:end'
73
89
  const MANAGED_NOTE = '// Regenerated by SvGrid Studio. Edits inside these markers are overwritten.'
@@ -47,6 +47,13 @@ export function resolveThemeTokens(theme?: ProjectTheme): Record<string, string>
47
47
  return resolveTokens(preset, mode, theme?.accent)
48
48
  }
49
49
 
50
+ /** Resolve the `--sg-*` tokens for a project theme in an EXPLICIT mode (for the
51
+ * generated app's light/dark switcher, which ships both token sets). */
52
+ export function resolveThemeTokensFor(theme: ProjectTheme | undefined, mode: 'light' | 'dark'): Record<string, string> {
53
+ const preset = getStudioTheme(theme?.preset) ?? defaultStudioTheme
54
+ return resolveTokens(preset, mode, theme?.accent)
55
+ }
56
+
50
57
  /** An inline `style` string (`--sg-x: y; ...; color-scheme; font-family`) for previews. */
51
58
  export function themeStyleString(theme?: ProjectTheme): string {
52
59
  const tokens = resolveThemeTokens(theme)
@@ -0,0 +1,472 @@
1
+ /**
2
+ * A curated catalogue of SvGrid UI-kit components a Studio screen can host as a
3
+ * `'component'` block (see `ComponentConfig` in `project.ts`) - entity-agnostic,
4
+ * usable on any screen including a freestanding one. Pure metadata: no Svelte
5
+ * import here (this subtree stays Svelte-free), so the designer holds the live
6
+ * component references and this module only describes what to configure and how
7
+ * to generate.
8
+ *
9
+ * Only "chrome" props (scalar values a form can drive) are listed - every
10
+ * component's real content is typically a Svelte `Snippet`, which this registry
11
+ * can't represent. A component with `hasContent` gets one extra literal-text
12
+ * field in the property panel (stored under the reserved `_content` props key)
13
+ * that becomes its plain-text children in both the live preview and codegen.
14
+ */
15
+
16
+ export type UiPropType = 'string' | 'number' | 'boolean' | 'select' | 'color'
17
+
18
+ export type UiComponentProp = {
19
+ key: string
20
+ label: string
21
+ type: UiPropType
22
+ /** Choices for `type: 'select'`. */
23
+ options?: string[]
24
+ default?: unknown
25
+ }
26
+
27
+ export type UiComponentSpec = {
28
+ /** Registry key - stored as `ComponentConfig.component`. */
29
+ key: string
30
+ label: string
31
+ category: string
32
+ /** Named export from `@svgrid/grid`. */
33
+ importName: string
34
+ /** Scalar "chrome" props only - see module doc. */
35
+ props: UiComponentProp[]
36
+ /** True if the component's real content is a `children` snippet - adds one
37
+ * literal-text field to the property panel, stored under `props._content`. */
38
+ hasContent?: boolean
39
+ contentLabel?: string
40
+ contentDefault?: string
41
+ /** Array / object props a scalar form can't express (a Timeline's `items`, a
42
+ * Sparkline's `data`). Each is emitted verbatim in codegen as `key={<expr>}`
43
+ * with a baked demo dataset the user then edits in Code view, and its `preview`
44
+ * value drives the live designer canvas. Not shown in the property panel. */
45
+ fixed?: Array<{ key: string; expr: string; preview: unknown }>
46
+ }
47
+
48
+ export const UI_COMPONENT_REGISTRY: ReadonlyArray<UiComponentSpec> = [
49
+ {
50
+ key: 'button',
51
+ label: 'Button',
52
+ category: 'Actions',
53
+ importName: 'SvButton',
54
+ props: [
55
+ { key: 'variant', label: 'Variant', type: 'select', options: ['primary', 'secondary', 'outline', 'ghost', 'danger'], default: 'primary' },
56
+ { key: 'size', label: 'Size', type: 'select', options: ['sm', 'md', 'lg'], default: 'md' },
57
+ { key: 'block', label: 'Full width', type: 'boolean', default: false },
58
+ { key: 'disabled', label: 'Disabled', type: 'boolean', default: false },
59
+ ],
60
+ hasContent: true,
61
+ contentLabel: 'Label',
62
+ contentDefault: 'Click me',
63
+ },
64
+ {
65
+ key: 'badge',
66
+ label: 'Badge',
67
+ category: 'Feedback',
68
+ importName: 'SvBadge',
69
+ props: [
70
+ { key: 'variant', label: 'Variant', type: 'select', options: ['neutral', 'accent', 'success', 'warning', 'danger', 'info'], default: 'neutral' },
71
+ { key: 'size', label: 'Size', type: 'select', options: ['sm', 'md'], default: 'md' },
72
+ { key: 'pill', label: 'Pill shape', type: 'boolean', default: true },
73
+ { key: 'dot', label: 'Show dot', type: 'boolean', default: false },
74
+ ],
75
+ hasContent: true,
76
+ contentLabel: 'Text',
77
+ contentDefault: 'Badge',
78
+ },
79
+ {
80
+ key: 'alert',
81
+ label: 'Alert',
82
+ category: 'Feedback',
83
+ importName: 'SvAlert',
84
+ props: [
85
+ { key: 'variant', label: 'Variant', type: 'select', options: ['info', 'success', 'warning', 'danger', 'neutral'], default: 'info' },
86
+ { key: 'title', label: 'Title', type: 'string' },
87
+ { key: 'dismissible', label: 'Dismissible', type: 'boolean', default: false },
88
+ { key: 'soft', label: 'Soft (tinted) style', type: 'boolean', default: false },
89
+ ],
90
+ hasContent: true,
91
+ contentLabel: 'Message',
92
+ contentDefault: 'This is an alert message.',
93
+ },
94
+ {
95
+ key: 'progress',
96
+ label: 'Progress bar',
97
+ category: 'Feedback',
98
+ importName: 'SvProgress',
99
+ props: [
100
+ { key: 'value', label: 'Value', type: 'number', default: 60 },
101
+ { key: 'max', label: 'Max', type: 'number', default: 100 },
102
+ { key: 'color', label: 'Color', type: 'select', options: ['accent', 'success', 'warning', 'danger'], default: 'accent' },
103
+ { key: 'size', label: 'Size', type: 'select', options: ['sm', 'md', 'lg'], default: 'md' },
104
+ { key: 'showLabel', label: 'Show percentage', type: 'boolean', default: false },
105
+ { key: 'striped', label: 'Striped', type: 'boolean', default: false },
106
+ ],
107
+ },
108
+ {
109
+ key: 'card',
110
+ label: 'Card',
111
+ category: 'Layout',
112
+ importName: 'SvCard',
113
+ props: [
114
+ { key: 'title', label: 'Title', type: 'string', default: 'Card title' },
115
+ { key: 'subtitle', label: 'Subtitle', type: 'string' },
116
+ { key: 'hoverable', label: 'Hover elevation', type: 'boolean', default: false },
117
+ { key: 'flush', label: 'Flush padding', type: 'boolean', default: false },
118
+ ],
119
+ },
120
+ {
121
+ key: 'divider',
122
+ label: 'Divider',
123
+ category: 'Layout',
124
+ importName: 'SvDivider',
125
+ props: [
126
+ { key: 'orientation', label: 'Orientation', type: 'select', options: ['horizontal', 'vertical'], default: 'horizontal' },
127
+ { key: 'label', label: 'Label', type: 'string' },
128
+ { key: 'align', label: 'Label align', type: 'select', options: ['start', 'center', 'end'], default: 'center' },
129
+ { key: 'dashed', label: 'Dashed', type: 'boolean', default: false },
130
+ ],
131
+ },
132
+ {
133
+ key: 'avatar',
134
+ label: 'Avatar',
135
+ category: 'Display',
136
+ importName: 'SvAvatar',
137
+ props: [
138
+ { key: 'name', label: 'Name', type: 'string', default: 'Jordan Lee' },
139
+ { key: 'src', label: 'Image URL', type: 'string' },
140
+ { key: 'size', label: 'Size', type: 'select', options: ['sm', 'md', 'lg'], default: 'md' },
141
+ { key: 'shape', label: 'Shape', type: 'select', options: ['circle', 'square'], default: 'circle' },
142
+ { key: 'status', label: 'Status', type: 'select', options: ['', 'online', 'offline', 'busy', 'away'], default: '' },
143
+ { key: 'color', label: 'Color override', type: 'color' },
144
+ ],
145
+ },
146
+ {
147
+ key: 'stat',
148
+ label: 'Stat',
149
+ category: 'Display',
150
+ importName: 'SvStat',
151
+ props: [
152
+ { key: 'label', label: 'Label', type: 'string', default: 'Revenue' },
153
+ { key: 'value', label: 'Value', type: 'string', default: '$48,200' },
154
+ { key: 'delta', label: 'Delta', type: 'string' },
155
+ { key: 'trend', label: 'Trend', type: 'select', options: ['', 'up', 'down', 'flat'], default: '' },
156
+ { key: 'hint', label: 'Hint', type: 'string' },
157
+ { key: 'invert', label: 'Invert trend colors', type: 'boolean', default: false },
158
+ ],
159
+ },
160
+
161
+ // --- Actions -------------------------------------------------------------
162
+ {
163
+ key: 'toggle-button',
164
+ label: 'Toggle button',
165
+ category: 'Actions',
166
+ importName: 'SvToggleButton',
167
+ props: [
168
+ { key: 'pressed', label: 'Pressed', type: 'boolean', default: false },
169
+ { key: 'size', label: 'Size', type: 'select', options: ['sm', 'md', 'lg'], default: 'md' },
170
+ { key: 'disabled', label: 'Disabled', type: 'boolean', default: false },
171
+ ],
172
+ hasContent: true,
173
+ contentLabel: 'Label',
174
+ contentDefault: 'Toggle',
175
+ },
176
+ {
177
+ key: 'switch',
178
+ label: 'Switch',
179
+ category: 'Actions',
180
+ importName: 'SvSwitchButton',
181
+ props: [
182
+ { key: 'checked', label: 'On', type: 'boolean', default: true },
183
+ { key: 'label', label: 'Field label', type: 'string' },
184
+ { key: 'onLabel', label: 'On label', type: 'string', default: 'On' },
185
+ { key: 'offLabel', label: 'Off label', type: 'string', default: 'Off' },
186
+ { key: 'disabled', label: 'Disabled', type: 'boolean', default: false },
187
+ ],
188
+ },
189
+
190
+ // --- Inputs --------------------------------------------------------------
191
+ {
192
+ key: 'text-input',
193
+ label: 'Text input',
194
+ category: 'Inputs',
195
+ importName: 'SvTextInput',
196
+ props: [
197
+ { key: 'label', label: 'Label', type: 'string', default: 'Name' },
198
+ { key: 'placeholder', label: 'Placeholder', type: 'string', default: 'Type here…' },
199
+ { key: 'type', label: 'Type', type: 'select', options: ['text', 'email', 'url', 'tel', 'search'], default: 'text' },
200
+ { key: 'size', label: 'Size', type: 'select', options: ['sm', 'md', 'lg'], default: 'md' },
201
+ { key: 'clearable', label: 'Clearable', type: 'boolean', default: false },
202
+ { key: 'disabled', label: 'Disabled', type: 'boolean', default: false },
203
+ { key: 'readonly', label: 'Read only', type: 'boolean', default: false },
204
+ ],
205
+ },
206
+ {
207
+ key: 'textarea',
208
+ label: 'Text area',
209
+ category: 'Inputs',
210
+ importName: 'SvTextArea',
211
+ props: [
212
+ { key: 'label', label: 'Label', type: 'string', default: 'Notes' },
213
+ { key: 'placeholder', label: 'Placeholder', type: 'string', default: 'Write something…' },
214
+ { key: 'rows', label: 'Rows', type: 'number', default: 3 },
215
+ { key: 'disabled', label: 'Disabled', type: 'boolean', default: false },
216
+ ],
217
+ },
218
+ {
219
+ key: 'number-input',
220
+ label: 'Number input',
221
+ category: 'Inputs',
222
+ importName: 'SvNumberInput',
223
+ props: [
224
+ { key: 'label', label: 'Label', type: 'string', default: 'Amount' },
225
+ { key: 'placeholder', label: 'Placeholder', type: 'string' },
226
+ { key: 'min', label: 'Min', type: 'number' },
227
+ { key: 'max', label: 'Max', type: 'number' },
228
+ { key: 'step', label: 'Step', type: 'number', default: 1 },
229
+ { key: 'prefix', label: 'Prefix', type: 'string' },
230
+ { key: 'suffix', label: 'Suffix', type: 'string' },
231
+ { key: 'disabled', label: 'Disabled', type: 'boolean', default: false },
232
+ ],
233
+ },
234
+ {
235
+ key: 'password-input',
236
+ label: 'Password input',
237
+ category: 'Inputs',
238
+ importName: 'SvPasswordInput',
239
+ props: [
240
+ { key: 'label', label: 'Label', type: 'string', default: 'Password' },
241
+ { key: 'placeholder', label: 'Placeholder', type: 'string', default: '••••••••' },
242
+ { key: 'revealable', label: 'Reveal toggle', type: 'boolean', default: true },
243
+ { key: 'showStrength', label: 'Strength meter', type: 'boolean', default: false },
244
+ { key: 'disabled', label: 'Disabled', type: 'boolean', default: false },
245
+ ],
246
+ },
247
+ {
248
+ key: 'color-input',
249
+ label: 'Color input',
250
+ category: 'Inputs',
251
+ importName: 'SvColorInput',
252
+ props: [
253
+ { key: 'label', label: 'Label', type: 'string', default: 'Brand color' },
254
+ { key: 'value', label: 'Value', type: 'color', default: '#6366f1' },
255
+ { key: 'disabled', label: 'Disabled', type: 'boolean', default: false },
256
+ ],
257
+ },
258
+ {
259
+ key: 'checkbox',
260
+ label: 'Checkbox',
261
+ category: 'Inputs',
262
+ importName: 'SvCheckBox',
263
+ props: [
264
+ { key: 'checked', label: 'Checked', type: 'boolean', default: true },
265
+ { key: 'disabled', label: 'Disabled', type: 'boolean', default: false },
266
+ ],
267
+ hasContent: true,
268
+ contentLabel: 'Label',
269
+ contentDefault: 'I agree to the terms',
270
+ },
271
+ {
272
+ key: 'rating',
273
+ label: 'Rating',
274
+ category: 'Inputs',
275
+ importName: 'SvRating',
276
+ props: [
277
+ { key: 'value', label: 'Value', type: 'number', default: 3 },
278
+ { key: 'max', label: 'Stars', type: 'number', default: 5 },
279
+ { key: 'allowHalf', label: 'Allow half', type: 'boolean', default: false },
280
+ { key: 'readOnly', label: 'Read only', type: 'boolean', default: false },
281
+ { key: 'size', label: 'Size', type: 'select', options: ['sm', 'md', 'lg'], default: 'md' },
282
+ ],
283
+ },
284
+ {
285
+ key: 'slider',
286
+ label: 'Slider',
287
+ category: 'Inputs',
288
+ importName: 'SvSlider',
289
+ props: [
290
+ { key: 'value', label: 'Value', type: 'number', default: 50 },
291
+ { key: 'min', label: 'Min', type: 'number', default: 0 },
292
+ { key: 'max', label: 'Max', type: 'number', default: 100 },
293
+ { key: 'step', label: 'Step', type: 'number', default: 1 },
294
+ { key: 'showValue', label: 'Show value', type: 'boolean', default: false },
295
+ { key: 'labels', label: 'Tick labels', type: 'select', options: ['none', 'endpoints', 'all'], default: 'none' },
296
+ ],
297
+ },
298
+ {
299
+ key: 'otp-input',
300
+ label: 'OTP input',
301
+ category: 'Inputs',
302
+ importName: 'SvOtpInput',
303
+ props: [
304
+ { key: 'label', label: 'Label', type: 'string', default: 'Verification code' },
305
+ { key: 'length', label: 'Digits', type: 'number', default: 6 },
306
+ { key: 'mask', label: 'Mask input', type: 'boolean', default: false },
307
+ { key: 'disabled', label: 'Disabled', type: 'boolean', default: false },
308
+ ],
309
+ },
310
+
311
+ // --- Feedback ------------------------------------------------------------
312
+ {
313
+ key: 'circular-progress',
314
+ label: 'Circular progress',
315
+ category: 'Feedback',
316
+ importName: 'SvCircularProgress',
317
+ props: [
318
+ { key: 'value', label: 'Value', type: 'number', default: 66 },
319
+ { key: 'max', label: 'Max', type: 'number', default: 100 },
320
+ { key: 'size', label: 'Diameter (px)', type: 'number', default: 52 },
321
+ { key: 'thickness', label: 'Thickness', type: 'number', default: 4 },
322
+ { key: 'color', label: 'Color', type: 'select', options: ['accent', 'success', 'warning', 'danger'], default: 'accent' },
323
+ { key: 'showLabel', label: 'Show percentage', type: 'boolean', default: true },
324
+ { key: 'indeterminate', label: 'Indeterminate', type: 'boolean', default: false },
325
+ ],
326
+ },
327
+ {
328
+ key: 'skeleton',
329
+ label: 'Skeleton',
330
+ category: 'Feedback',
331
+ importName: 'SvSkeleton',
332
+ props: [
333
+ { key: 'variant', label: 'Variant', type: 'select', options: ['text', 'rect', 'circle'], default: 'text' },
334
+ { key: 'lines', label: 'Lines (text)', type: 'number', default: 3 },
335
+ { key: 'width', label: 'Width (CSS)', type: 'string' },
336
+ { key: 'height', label: 'Height (CSS)', type: 'string' },
337
+ { key: 'animated', label: 'Animated', type: 'boolean', default: true },
338
+ ],
339
+ },
340
+
341
+ // --- Display -------------------------------------------------------------
342
+ {
343
+ key: 'chip',
344
+ label: 'Chip',
345
+ category: 'Display',
346
+ importName: 'SvChip',
347
+ props: [
348
+ { key: 'variant', label: 'Variant', type: 'select', options: ['neutral', 'accent', 'success', 'warning', 'danger', 'info'], default: 'neutral' },
349
+ { key: 'size', label: 'Size', type: 'select', options: ['sm', 'md'], default: 'md' },
350
+ { key: 'solid', label: 'Solid', type: 'boolean', default: false },
351
+ { key: 'removable', label: 'Removable', type: 'boolean', default: false },
352
+ { key: 'disabled', label: 'Disabled', type: 'boolean', default: false },
353
+ ],
354
+ hasContent: true,
355
+ contentLabel: 'Text',
356
+ contentDefault: 'Chip',
357
+ },
358
+ {
359
+ key: 'empty-state',
360
+ label: 'Empty state',
361
+ category: 'Display',
362
+ importName: 'SvEmptyState',
363
+ props: [
364
+ { key: 'title', label: 'Title', type: 'string', default: 'No results yet' },
365
+ { key: 'description', label: 'Description', type: 'string', default: 'Try adjusting your filters or add a new record.' },
366
+ { key: 'compact', label: 'Compact', type: 'boolean', default: false },
367
+ ],
368
+ },
369
+ {
370
+ key: 'timeline',
371
+ label: 'Timeline',
372
+ category: 'Display',
373
+ importName: 'SvTimeline',
374
+ props: [],
375
+ fixed: [
376
+ {
377
+ key: 'items',
378
+ expr: `[{ title: 'Order placed', time: '09:12', color: '#16a34a' }, { title: 'Packed', time: '11:30', description: 'Ready to ship' }, { title: 'Shipped', time: '14:40', color: '#6366f1' }, { title: 'Delivered', time: '17:05' }]`,
379
+ preview: [
380
+ { title: 'Order placed', time: '09:12', color: '#16a34a' },
381
+ { title: 'Packed', time: '11:30', description: 'Ready to ship' },
382
+ { title: 'Shipped', time: '14:40', color: '#6366f1' },
383
+ { title: 'Delivered', time: '17:05' },
384
+ ],
385
+ },
386
+ ],
387
+ },
388
+ {
389
+ key: 'sparkline',
390
+ label: 'Sparkline',
391
+ category: 'Display',
392
+ importName: 'SvSparkline',
393
+ props: [
394
+ { key: 'type', label: 'Type', type: 'select', options: ['line', 'area', 'bar', 'winloss'], default: 'area' },
395
+ { key: 'color', label: 'Color', type: 'color', default: '#6366f1' },
396
+ { key: 'width', label: 'Width (px)', type: 'number', default: 120 },
397
+ { key: 'height', label: 'Height (px)', type: 'number', default: 32 },
398
+ ],
399
+ fixed: [
400
+ { key: 'data', expr: `[4, 8, 5, 9, 7, 11, 8, 13, 10, 15]`, preview: [4, 8, 5, 9, 7, 11, 8, 13, 10, 15] },
401
+ ],
402
+ },
403
+ {
404
+ key: 'avatar-group',
405
+ label: 'Avatar group',
406
+ category: 'Display',
407
+ importName: 'SvAvatarGroup',
408
+ props: [
409
+ { key: 'max', label: 'Max shown', type: 'number', default: 4 },
410
+ { key: 'size', label: 'Size', type: 'select', options: ['sm', 'md', 'lg'], default: 'md' },
411
+ ],
412
+ fixed: [
413
+ {
414
+ key: 'avatars',
415
+ expr: `[{ name: 'Jordan Lee' }, { name: 'Sam Rivera' }, { name: 'Alex Kim' }, { name: 'Priya Patel' }, { name: 'Chris Diaz' }]`,
416
+ preview: [{ name: 'Jordan Lee' }, { name: 'Sam Rivera' }, { name: 'Alex Kim' }, { name: 'Priya Patel' }, { name: 'Chris Diaz' }],
417
+ },
418
+ ],
419
+ },
420
+
421
+ // --- Navigation ----------------------------------------------------------
422
+ {
423
+ key: 'pagination',
424
+ label: 'Pagination',
425
+ category: 'Navigation',
426
+ importName: 'SvPagination',
427
+ props: [
428
+ { key: 'page', label: 'Current page', type: 'number', default: 1 },
429
+ { key: 'pageCount', label: 'Page count', type: 'number', default: 5 },
430
+ { key: 'size', label: 'Size', type: 'select', options: ['sm', 'md', 'lg'], default: 'md' },
431
+ { key: 'showFirstLast', label: 'First / last', type: 'boolean', default: true },
432
+ { key: 'showPrevNext', label: 'Prev / next', type: 'boolean', default: true },
433
+ { key: 'siblingCount', label: 'Sibling count', type: 'number', default: 1 },
434
+ ],
435
+ },
436
+ {
437
+ key: 'breadcrumb',
438
+ label: 'Breadcrumb',
439
+ category: 'Navigation',
440
+ importName: 'SvBreadcrumb',
441
+ props: [],
442
+ fixed: [
443
+ {
444
+ key: 'items',
445
+ expr: `[{ label: 'Home', href: '/' }, { label: 'Orders', href: '/orders' }, { label: '#1024' }]`,
446
+ preview: [{ label: 'Home', href: '/' }, { label: 'Orders', href: '/orders' }, { label: '#1024' }],
447
+ },
448
+ ],
449
+ },
450
+ {
451
+ key: 'stepper',
452
+ label: 'Stepper',
453
+ category: 'Navigation',
454
+ importName: 'SvStepper',
455
+ props: [
456
+ { key: 'current', label: 'Current step', type: 'number', default: 1 },
457
+ { key: 'orientation', label: 'Orientation', type: 'select', options: ['horizontal', 'vertical'], default: 'horizontal' },
458
+ { key: 'linear', label: 'Linear', type: 'boolean', default: true },
459
+ ],
460
+ fixed: [
461
+ {
462
+ key: 'steps',
463
+ expr: `[{ label: 'Cart' }, { label: 'Shipping' }, { label: 'Payment' }, { label: 'Review' }]`,
464
+ preview: [{ label: 'Cart' }, { label: 'Shipping' }, { label: 'Payment' }, { label: 'Review' }],
465
+ },
466
+ ],
467
+ },
468
+ ]
469
+
470
+ export function uiComponentSpec(key: string): UiComponentSpec | undefined {
471
+ return UI_COMPONENT_REGISTRY.find((s) => s.key === key)
472
+ }
@@ -126,6 +126,32 @@ describe('createKitHandlers server-enforced validation', () => {
126
126
  const res = await post(handlers, { kind: 'mutate', op: 'create', input: { id: '9', age: 999 } })
127
127
  expect(res.status).toBe(200)
128
128
  })
129
+
130
+ it('business-rule hooks transform the payload, reject writes, and fire after-effects', async () => {
131
+ const audited: string[] = []
132
+ const handlers = createKitHandlers({
133
+ schema: vSchema,
134
+ source: createInMemoryDataSource<Customer>([], vSchema),
135
+ hooks: {
136
+ beforeCreate: ({ values }) => {
137
+ const v = values as Record<string, unknown>
138
+ if (v['name'] == null || v['name'] === '') throw new Error('name required')
139
+ if (v['tier'] == null) v['tier'] = 'free' // default a field server-side
140
+ },
141
+ afterCreate: ({ row }) => { audited.push(String((row as Record<string, unknown>).id)) },
142
+ },
143
+ })
144
+ // transform: tier defaulted to 'free', afterCreate fired
145
+ const ok = await post(handlers, { kind: 'mutate', op: 'create', input: { id: '1', name: 'Ada' } })
146
+ expect(ok.status).toBe(200)
147
+ expect((await ok.json()).tier).toBe('free')
148
+ expect(audited).toEqual(['1'])
149
+ // reject: a thrown before-hook -> 422 with the message, no after-effect
150
+ const bad = await post(handlers, { kind: 'mutate', op: 'create', input: { id: '2' } })
151
+ expect(bad.status).toBe(422)
152
+ expect((await bad.json()).error).toBe('name required')
153
+ expect(audited).toEqual(['1']) // unchanged - the write never happened
154
+ })
129
155
  })
130
156
 
131
157
  describe('createKitHandlers audit hook', () => {