@svgrid/enterprise 1.2.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cdn/svgrid-enterprise.svelte-external.js +26039 -805
- package/dist/designer/assets/index-Dp44bTid.js +939 -0
- package/dist/designer/assets/index-RJp6x8tw.css +1 -0
- package/dist/designer/assets/jszip.min-CjMo-QGg.js +2 -0
- package/dist/designer/index.html +13 -0
- package/dist/node/studio.js +22554 -0
- package/package.json +10 -3
- package/src/SvAuthGate.svelte +115 -0
- package/src/SvBoard.dom.test.ts +67 -0
- package/src/SvBoard.svelte +192 -0
- package/src/SvExportMenu.svelte +553 -0
- package/src/SvFileInput.svelte +113 -0
- package/src/SvGridEditPanel.dom.test.ts +146 -0
- package/src/SvGridEditPanel.svelte +793 -0
- package/src/SvGridMasterDetail.svelte +95 -0
- package/src/SvImportDialog.svelte +1020 -0
- package/src/SvLookupInput.svelte +180 -0
- package/src/SvRecordDetail.dom.test.ts +137 -0
- package/src/SvRecordDetail.svelte +288 -0
- package/src/SvSchedule.dom.test.ts +57 -0
- package/src/SvSchedule.svelte +156 -0
- package/src/SvSchemaChart.svelte +233 -0
- package/src/SvSchemaDashboard.svelte +130 -0
- package/src/ai-export-pdf.test.ts +74 -0
- package/src/ai-export-xlsx.test.ts +87 -0
- package/src/ai-export.test.ts +110 -0
- package/src/ai.ts +1188 -782
- package/src/edit-panel.test.ts +213 -0
- package/src/edit-panel.ts +228 -0
- package/src/export-conditional.test.ts +60 -0
- package/src/export-conditional.ts +94 -0
- package/src/export-ooxml.test.ts +152 -0
- package/src/export-ooxml.ts +485 -0
- package/src/export-pdf.test.ts +138 -0
- package/src/export-pdf.ts +245 -0
- package/src/export-print.test.ts +66 -0
- package/src/export-print.ts +132 -0
- package/src/export-serialize.test.ts +56 -0
- package/src/export-serialize.ts +24 -0
- package/src/export-xls.ts +196 -0
- package/src/export-xlsx-roundtrip.test.ts +120 -0
- package/src/export-xlsx.test.ts +150 -0
- package/src/export.test.ts +349 -0
- package/src/export.ts +1452 -549
- package/src/import-automap.test.ts +131 -0
- package/src/import-hardening.test.ts +158 -0
- package/src/import.ts +1042 -648
- package/src/index.ts +282 -0
- package/src/install.ts +134 -114
- package/src/license-core.test.ts +23 -0
- package/src/license-core.ts +35 -0
- package/src/license.ts +103 -90
- package/src/master-detail.test.ts +61 -0
- package/src/master-detail.ts +42 -0
- package/src/print.ts +107 -127
- package/src/schema-designer.test.ts +121 -0
- package/src/schema-designer.ts +142 -0
- package/src/schema.test.ts +268 -0
- package/src/schema.ts +499 -0
- package/src/smart-shim.ts +107 -105
- package/src/sources/aggregate.test.ts +79 -0
- package/src/sources/aggregate.ts +102 -0
- package/src/sources/auth-supabase.test.ts +80 -0
- package/src/sources/auth-supabase.ts +87 -0
- package/src/sources/dashboard.kpi.test.ts +50 -0
- package/src/sources/dashboard.test.ts +61 -0
- package/src/sources/dashboard.ts +136 -0
- package/src/sources/field-inference.test.ts +60 -0
- package/src/sources/field-inference.ts +63 -0
- package/src/sources/filters.test.ts +58 -0
- package/src/sources/filters.ts +64 -0
- package/src/sources/index.ts +67 -0
- package/src/sources/introspect-supabase.test.ts +100 -0
- package/src/sources/introspect-supabase.ts +119 -0
- package/src/sources/realtime-supabase.test.ts +93 -0
- package/src/sources/realtime-supabase.ts +99 -0
- package/src/sources/relation-lookup.test.ts +99 -0
- package/src/sources/relation-lookup.ts +128 -0
- package/src/sources/rest-adapters.test.ts +95 -0
- package/src/sources/rest-adapters.ts +120 -0
- package/src/sources/rest.test.ts +104 -0
- package/src/sources/rest.ts +129 -0
- package/src/sources/schema-from-columns.test.ts +106 -0
- package/src/sources/schema-from-columns.ts +88 -0
- package/src/sources/supabase.test.ts +110 -0
- package/src/sources/supabase.ts +99 -0
- package/src/sources/with-entity-rules.test.ts +104 -0
- package/src/sources/with-entity-rules.ts +78 -0
- package/src/sources/with-relation-labels.test.ts +75 -0
- package/src/sources/with-relation-labels.ts +73 -0
- package/src/studio/bug-report.test.ts +101 -0
- package/src/studio/bug-report.ts +165 -0
- package/src/studio/cli.test.ts +187 -0
- package/src/studio/cli.ts +112 -0
- package/src/studio/csv.test.ts +90 -0
- package/src/studio/csv.ts +165 -0
- package/src/studio/db-connect-string.test.ts +94 -0
- package/src/studio/db-connect-string.ts +128 -0
- package/src/studio/emit-project.test.ts +923 -0
- package/src/studio/emit-project.ts +1273 -0
- package/src/studio/emit-schema.test.ts +94 -0
- package/src/studio/emit-schema.ts +920 -0
- package/src/studio/index.ts +195 -0
- package/src/studio/introspect-db.test.ts +186 -0
- package/src/studio/introspect-db.ts +312 -0
- package/src/studio/introspect-prisma.test.ts +99 -0
- package/src/studio/introspect-prisma.ts +175 -0
- package/src/studio/introspect.test.ts +172 -0
- package/src/studio/introspect.ts +310 -0
- package/src/studio/pipeline.test.ts +42 -0
- package/src/studio/project-robust.test.ts +157 -0
- package/src/studio/project.test.ts +473 -0
- package/src/studio/project.ts +916 -0
- package/src/studio/sample-data.test.ts +58 -0
- package/src/studio/sample-data.ts +200 -0
- package/src/studio/samples/ats.ts +202 -0
- package/src/studio/samples/clinic.ts +177 -0
- package/src/studio/samples/crm.ts +250 -0
- package/src/studio/samples/ecommerce.ts +187 -0
- package/src/studio/samples/events.ts +199 -0
- package/src/studio/samples/fleet.ts +184 -0
- package/src/studio/samples/gym.ts +213 -0
- package/src/studio/samples/hr.ts +193 -0
- package/src/studio/samples/index.ts +55 -0
- package/src/studio/samples/insurance.ts +195 -0
- package/src/studio/samples/inventory.ts +182 -0
- package/src/studio/samples/invoicing.ts +162 -0
- package/src/studio/samples/library.ts +180 -0
- package/src/studio/samples/live-data.test.ts +98 -0
- package/src/studio/samples/live-data.ts +308 -0
- package/src/studio/samples/projects.ts +190 -0
- package/src/studio/samples/realestate.ts +196 -0
- package/src/studio/samples/restaurant.ts +187 -0
- package/src/studio/samples/samples.test.ts +208 -0
- package/src/studio/samples/school.ts +197 -0
- package/src/studio/samples/seed-floor.test.ts +25 -0
- package/src/studio/samples/shared.ts +305 -0
- package/src/studio/samples/subscriptions.ts +183 -0
- package/src/studio/samples/support.ts +182 -0
- package/src/studio/scaffold-app.test.ts +91 -0
- package/src/studio/scaffold-app.ts +161 -0
- package/src/studio/scaffold.test.ts +178 -0
- package/src/studio/scaffold.ts +374 -0
- package/src/studio/themes.ts +172 -0
- package/src/studio/verify.test.ts +47 -0
- package/src/studio/verify.ts +79 -0
- package/src/sveltekit/in-memory.test.ts +104 -0
- package/src/sveltekit/in-memory.ts +129 -0
- package/src/sveltekit/index.ts +23 -0
- package/src/sveltekit/query-plan.test.ts +109 -0
- package/src/sveltekit/query-plan.ts +125 -0
- package/src/sveltekit/sql-source.test.ts +185 -0
- package/src/sveltekit/sql-source.ts +166 -0
- package/src/sveltekit/sql.test.ts +80 -0
- package/src/sveltekit/sql.ts +131 -0
- package/src/sveltekit/transport.test.ts +175 -0
- package/src/sveltekit/transport.ts +254 -0
- package/src/sveltekit/types.ts +11 -0
- package/src/upgrade-prompt.ts +149 -148
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scaffold - turn an `EntitySchema` into runnable SvelteKit files. The shared
|
|
3
|
+
* code-emit core: both the AI generator (MCP `scaffold_entity` tool) and the
|
|
4
|
+
* visual designer's "Generate code" action call this one function, so there is
|
|
5
|
+
* a single source of truth for what generated code looks like.
|
|
6
|
+
*
|
|
7
|
+
* Every generated file wraps its body in `svgrid:managed` markers. A future
|
|
8
|
+
* regeneration replaces only the managed region, so edits a user makes outside
|
|
9
|
+
* the markers survive - idempotent generation, the thing that makes a
|
|
10
|
+
* generator trustworthy enough to keep using.
|
|
11
|
+
*
|
|
12
|
+
* Pure string building. No filesystem, no Svelte. The `validate` (Standard
|
|
13
|
+
* Schema) functions on a field cannot be serialized to a literal, so they are
|
|
14
|
+
* omitted from the emitted schema with a note to re-add them by hand.
|
|
15
|
+
*/
|
|
16
|
+
import { resolveIdField, type EntityField, type EntitySchema } from '../schema.js'
|
|
17
|
+
import type { SqlDialectName } from './introspect-db.js'
|
|
18
|
+
|
|
19
|
+
export type GeneratedFile = {
|
|
20
|
+
path: string
|
|
21
|
+
contents: string
|
|
22
|
+
description: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ScaffoldOptions = {
|
|
26
|
+
/** Route segment for the page, defaults to the schema name. e.g. `'customers'`. */
|
|
27
|
+
route?: string
|
|
28
|
+
/** API route the page talks to, defaults to `/api/{route}`. */
|
|
29
|
+
apiRoute?: string
|
|
30
|
+
/** `$lib` path for the generated schema module, defaults to `$lib/{route}.schema`. */
|
|
31
|
+
schemaImport?: string
|
|
32
|
+
/**
|
|
33
|
+
* Backend the generated API route uses:
|
|
34
|
+
* - `'memory'` (default): `createInMemoryDataSource` - runs immediately, no DB.
|
|
35
|
+
* - `'sql'`: `createSqlDataSource` with an `execute(text, params)` stub you fill in.
|
|
36
|
+
* - a dialect (`'postgres'` | `'supabase'` | `'mysql'` | `'mssql'` | `'sqlite'`):
|
|
37
|
+
* a fully-connected route wired to that driver via `process.env.DATABASE_URL`.
|
|
38
|
+
*/
|
|
39
|
+
dataSource?: 'memory' | 'sql' | SqlDialectName
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Per-dialect driver wiring for a connected `+server.ts`. */
|
|
43
|
+
type DriverWiring = { imports: string; setup: string; dialectArg: string; execBody: string }
|
|
44
|
+
const DRIVERS: Record<'postgres' | 'mysql' | 'mssql' | 'sqlite', DriverWiring> = {
|
|
45
|
+
postgres: {
|
|
46
|
+
imports: `import pg from 'pg'`,
|
|
47
|
+
setup: `const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL })`,
|
|
48
|
+
dialectArg: `\n dialect: { placeholders: '$', ilike: true },`,
|
|
49
|
+
execBody: `const result = await pool.query(text, params)\n return result.rows`,
|
|
50
|
+
},
|
|
51
|
+
mysql: {
|
|
52
|
+
imports: `import mysql from 'mysql2/promise'`,
|
|
53
|
+
setup: `const pool = mysql.createPool(process.env.DATABASE_URL ?? '')`,
|
|
54
|
+
dialectArg: ``,
|
|
55
|
+
execBody: `const [rows] = await pool.query(text, params)\n return rows as Record<string, unknown>[]`,
|
|
56
|
+
},
|
|
57
|
+
mssql: {
|
|
58
|
+
imports: `import mssql from 'mssql'`,
|
|
59
|
+
setup: `const poolPromise = mssql.connect(process.env.DATABASE_URL ?? '')`,
|
|
60
|
+
dialectArg: `\n dialect: { placeholders: '@' },`,
|
|
61
|
+
execBody: `const pool = await poolPromise\n const request = pool.request()\n params.forEach((p, i) => request.input('p' + (i + 1), p))\n const result = await request.query(text)\n return result.recordset as Record<string, unknown>[]`,
|
|
62
|
+
},
|
|
63
|
+
sqlite: {
|
|
64
|
+
imports: `import Database from 'better-sqlite3'`,
|
|
65
|
+
setup: `const db = new Database(process.env.DATABASE_URL ?? 'data.db')`,
|
|
66
|
+
dialectArg: ``,
|
|
67
|
+
execBody: `return db.prepare(text).all(...params) as Record<string, unknown>[]`,
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const MANAGED_START = '// svgrid:managed:start'
|
|
72
|
+
export const MANAGED_END = '// svgrid:managed:end'
|
|
73
|
+
const MANAGED_NOTE = '// Regenerated by SvGrid Studio. Edits inside these markers are overwritten.'
|
|
74
|
+
|
|
75
|
+
function pascal(name: string): string {
|
|
76
|
+
return name
|
|
77
|
+
.replace(/[_\-\s]+/g, ' ')
|
|
78
|
+
.replace(/([a-z\d])([A-Z])/g, '$1 $2')
|
|
79
|
+
.split(' ')
|
|
80
|
+
.filter(Boolean)
|
|
81
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
82
|
+
.join('')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function camel(name: string): string {
|
|
86
|
+
const p = pascal(name)
|
|
87
|
+
return p.charAt(0).toLowerCase() + p.slice(1)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function tsType(field: EntityField): string {
|
|
91
|
+
switch (field.type) {
|
|
92
|
+
case 'number':
|
|
93
|
+
return 'number'
|
|
94
|
+
case 'boolean':
|
|
95
|
+
return 'boolean'
|
|
96
|
+
default:
|
|
97
|
+
return 'string'
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function fieldLiteral(field: EntityField): string {
|
|
102
|
+
const parts = [`field: ${JSON.stringify(field.field)}`, `type: ${JSON.stringify(field.type)}`]
|
|
103
|
+
if (field.label) parts.push(`label: ${JSON.stringify(field.label)}`)
|
|
104
|
+
if (field.primaryKey) parts.push('primaryKey: true')
|
|
105
|
+
if (field.readonly) parts.push('readonly: true')
|
|
106
|
+
if (field.required) parts.push('required: true')
|
|
107
|
+
if (field.hidden) parts.push(`hidden: ${JSON.stringify(field.hidden)}`)
|
|
108
|
+
if (field.options) parts.push(`options: ${JSON.stringify(field.options)}`)
|
|
109
|
+
if (field.relation) parts.push(`relation: ${JSON.stringify(field.relation)}`)
|
|
110
|
+
return ` { ${parts.join(', ')} }`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function schemaLiteral(schema: EntitySchema, constName: string, rowType: string): string {
|
|
114
|
+
const head = [` name: ${JSON.stringify(schema.name)}`]
|
|
115
|
+
if (schema.label) head.push(` label: ${JSON.stringify(schema.label)}`)
|
|
116
|
+
if (schema.idField) head.push(` idField: ${JSON.stringify(schema.idField)}`)
|
|
117
|
+
const fields = schema.fields.map(fieldLiteral).join(',\n')
|
|
118
|
+
return `export const ${constName}: EntitySchema<${rowType}> = {\n${head.join(',\n')},\n fields: [\n${fields}\n ],\n}`
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function managed(body: string): string {
|
|
122
|
+
return `${MANAGED_START}\n${MANAGED_NOTE}\n${body}\n${MANAGED_END}`
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function scaffold(schema: EntitySchema, options: ScaffoldOptions = {}): { files: GeneratedFile[] } {
|
|
126
|
+
const route = options.route ?? schema.name
|
|
127
|
+
const apiRoute = options.apiRoute ?? `/api/${route}`
|
|
128
|
+
const schemaImport = options.schemaImport ?? `$lib/${route}.schema`
|
|
129
|
+
const constName = `${camel(schema.name)}Schema`
|
|
130
|
+
const rowType = `${pascal(schema.name)}Row`
|
|
131
|
+
const idField = resolveIdField(schema)
|
|
132
|
+
const label = schema.label ?? schema.name
|
|
133
|
+
|
|
134
|
+
const rowFields = schema.fields
|
|
135
|
+
.map((f) => ` ${f.field}: ${tsType(f)}`)
|
|
136
|
+
.join('\n')
|
|
137
|
+
|
|
138
|
+
// 1. The schema module ($lib).
|
|
139
|
+
const schemaFile: GeneratedFile = {
|
|
140
|
+
path: `src/lib/${route}.schema.ts`,
|
|
141
|
+
description: 'Entity schema + row type. Re-add any Standard Schema `validate` functions here by hand.',
|
|
142
|
+
contents: `import type { EntitySchema } from '@svgrid/enterprise'
|
|
143
|
+
|
|
144
|
+
${managed(`export type ${rowType} = {
|
|
145
|
+
${rowFields}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
${schemaLiteral(schema, constName, rowType)}`)}
|
|
149
|
+
`,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 2. The API route: SvelteKit +server.ts using the transport handlers.
|
|
153
|
+
const dataSource = options.dataSource ?? 'memory'
|
|
154
|
+
const driver = dataSource === 'supabase' ? DRIVERS.postgres : DRIVERS[dataSource as 'postgres']
|
|
155
|
+
let serverImport: string
|
|
156
|
+
let serverBody: string
|
|
157
|
+
let serverDescription: string
|
|
158
|
+
|
|
159
|
+
if (driver) {
|
|
160
|
+
// Fully-connected route: driver + DATABASE_URL, no manual editing needed.
|
|
161
|
+
serverImport = `${driver.imports}
|
|
162
|
+
import { createKitHandlers, createSqlDataSource } from '@svgrid/enterprise'`
|
|
163
|
+
serverBody = `${driver.setup}
|
|
164
|
+
|
|
165
|
+
const source = createSqlDataSource<${rowType}>({
|
|
166
|
+
schema: ${constName},
|
|
167
|
+
table: ${JSON.stringify(schema.name)},${driver.dialectArg}
|
|
168
|
+
execute: async (text, params) => {
|
|
169
|
+
${driver.execBody}
|
|
170
|
+
},
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
export const { POST } = createKitHandlers({ schema: ${constName}, source })`
|
|
174
|
+
serverDescription = `API route connected to your ${dataSource} database via process.env.DATABASE_URL.`
|
|
175
|
+
} else if (dataSource === 'sql') {
|
|
176
|
+
serverImport = `import { createKitHandlers, createSqlDataSource } from '@svgrid/enterprise'`
|
|
177
|
+
serverBody = `// Wire execute() to your SQL client: run \`text\` with \`params\`, return the rows.
|
|
178
|
+
// Works with Drizzle (db.execute), postgres.js, better-sqlite3, mysql2, ...
|
|
179
|
+
const source = createSqlDataSource<${rowType}>({
|
|
180
|
+
schema: ${constName},
|
|
181
|
+
table: ${JSON.stringify(schema.name)},
|
|
182
|
+
dialect: { placeholders: '$', ilike: true },
|
|
183
|
+
execute: async (text, params) => {
|
|
184
|
+
throw new Error('TODO: run the query with your database client and return the rows.')
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
export const { POST } = createKitHandlers({ schema: ${constName}, source })`
|
|
189
|
+
serverDescription = 'API route. Fill in execute() with your SQL client (Drizzle / postgres.js / ...).'
|
|
190
|
+
} else {
|
|
191
|
+
serverImport = `import { createInMemoryDataSource, createKitHandlers } from '@svgrid/enterprise'`
|
|
192
|
+
serverBody = `// Starts empty and in-memory so the screen runs immediately. Swap this for
|
|
193
|
+
// a database-backed source (scaffold with dataSource:"sql" or a dialect) when ready.
|
|
194
|
+
const source = createInMemoryDataSource<${rowType}>([], ${constName})
|
|
195
|
+
|
|
196
|
+
export const { POST } = createKitHandlers({ schema: ${constName}, source })`
|
|
197
|
+
serverDescription = 'API route using an in-memory source. Scaffold with a dialect for a real database.'
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const serverFile: GeneratedFile = {
|
|
201
|
+
path: `src/routes${apiRoute}/+server.ts`,
|
|
202
|
+
description: serverDescription,
|
|
203
|
+
contents: `${serverImport}
|
|
204
|
+
import { ${constName}, type ${rowType} } from '${schemaImport}'
|
|
205
|
+
|
|
206
|
+
${managed(serverBody)}
|
|
207
|
+
`,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Relation (foreign-key) fields become searchable lookups in the edit form.
|
|
211
|
+
// Each lookup queries the related entity's API route, so scaffold those tables
|
|
212
|
+
// too (e.g. `npx @svgrid/studio add <relatedTable>`).
|
|
213
|
+
const relationFields = schema.fields.filter(
|
|
214
|
+
(f): f is EntityField & { relation: NonNullable<EntityField['relation']> } =>
|
|
215
|
+
f.type === 'relation' && !!f.relation,
|
|
216
|
+
)
|
|
217
|
+
const hasLookups = relationFields.length > 0
|
|
218
|
+
const lookupDecls = relationFields
|
|
219
|
+
.map((f) => {
|
|
220
|
+
const name = `${camel(f.field)}Lookup`
|
|
221
|
+
const endpoint = JSON.stringify(`/api/${f.relation.entity}`)
|
|
222
|
+
return ` const ${name} = createRelationLookup<Record<string, unknown>>({
|
|
223
|
+
source: createKitDataSource({ endpoint: ${endpoint} }),
|
|
224
|
+
labelField: ${JSON.stringify(f.relation.labelField)},
|
|
225
|
+
})`
|
|
226
|
+
})
|
|
227
|
+
.join('\n')
|
|
228
|
+
const lookupsMap = ` const lookups = { ${relationFields
|
|
229
|
+
.map((f) => `${JSON.stringify(f.field)}: ${camel(f.field)}Lookup`)
|
|
230
|
+
.join(', ')} }`
|
|
231
|
+
const lookupsSection = hasLookups ? `\n\n${lookupDecls}\n${lookupsMap}` : ''
|
|
232
|
+
|
|
233
|
+
// 3. The page: a full data screen - search, server sort + paging,
|
|
234
|
+
// multi-select delete (optimistic), create/edit, loading/error/empty.
|
|
235
|
+
const pageBody = ` const source = createKitDataSource<${rowType}>({ endpoint: '${apiRoute}' })
|
|
236
|
+
const columns = schemaToColumns(${constName})${lookupsSection}
|
|
237
|
+
|
|
238
|
+
let state = $state<ServerState<${rowType}>>({
|
|
239
|
+
rows: [], total: 0, loading: false, saving: false, error: null,
|
|
240
|
+
pageIndex: 0, pageSize: 25, pageCount: 1, sortModel: [], filterModel: {},
|
|
241
|
+
})
|
|
242
|
+
const controller = createServerDataSource<${rowType}>(source, {
|
|
243
|
+
pageSize: 25,
|
|
244
|
+
optimistic: true,
|
|
245
|
+
getRowId: (r) => String(r.${idField}),
|
|
246
|
+
onChange: (s) => (state = s),
|
|
247
|
+
})
|
|
248
|
+
controller.refresh()
|
|
249
|
+
|
|
250
|
+
let selected = $state<${rowType}[]>([])
|
|
251
|
+
let editing = $state<${rowType} | null | undefined>(undefined) // undefined = closed, null = create
|
|
252
|
+
|
|
253
|
+
async function save({ mode, id, values }: { mode: 'create' | 'edit'; id: string | null; values: Partial<${rowType}> }) {
|
|
254
|
+
if (mode === 'create') {
|
|
255
|
+
await controller.createRow(values)
|
|
256
|
+
controller.setPage(state.pageCount - 1) // jump to the new row (appended at the end)
|
|
257
|
+
} else if (id) {
|
|
258
|
+
await controller.updateRow(id, values)
|
|
259
|
+
}
|
|
260
|
+
editing = undefined
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function removeSelected() {
|
|
264
|
+
for (const row of selected) await controller.deleteRow(String(row.${idField}))
|
|
265
|
+
selected = []
|
|
266
|
+
}`
|
|
267
|
+
|
|
268
|
+
const pageFile: GeneratedFile = {
|
|
269
|
+
path: `src/routes/${route}/+page.svelte`,
|
|
270
|
+
description: 'The data screen: sort, filter, search, paging, multi-select delete, and create/edit - wired to the API route.',
|
|
271
|
+
contents: `<script lang="ts">
|
|
272
|
+
import { SvGrid, createServerDataSource } from '@svgrid/grid'
|
|
273
|
+
import {
|
|
274
|
+
SvGridEditPanel,
|
|
275
|
+
createKitDataSource,${hasLookups ? '\n createRelationLookup,' : ''}
|
|
276
|
+
schemaToColumns,
|
|
277
|
+
type ServerState,
|
|
278
|
+
} from '@svgrid/enterprise'
|
|
279
|
+
import { ${constName}, type ${rowType} } from '${schemaImport}'
|
|
280
|
+
|
|
281
|
+
${managed(pageBody)}
|
|
282
|
+
</script>
|
|
283
|
+
|
|
284
|
+
<div class="page">
|
|
285
|
+
<header class="page__toolbar">
|
|
286
|
+
<h2 class="page__title">${label}</h2>
|
|
287
|
+
<div class="page__actions">
|
|
288
|
+
<button onclick={() => (editing = null)}>New</button>
|
|
289
|
+
<button disabled={selected.length === 0} onclick={removeSelected}>
|
|
290
|
+
Delete {selected.length > 0 ? '(' + selected.length + ')' : ''}
|
|
291
|
+
</button>
|
|
292
|
+
</div>
|
|
293
|
+
</header>
|
|
294
|
+
|
|
295
|
+
{#if state.error}<p class="page__error" role="alert">{state.error}</p>{/if}
|
|
296
|
+
|
|
297
|
+
<SvGrid
|
|
298
|
+
data={state.rows}
|
|
299
|
+
{columns}
|
|
300
|
+
loading={state.loading}
|
|
301
|
+
loadingOverlay
|
|
302
|
+
fitColumns
|
|
303
|
+
enableRowSummaries={false}
|
|
304
|
+
showRowSelection
|
|
305
|
+
enableCellSelection
|
|
306
|
+
sortable
|
|
307
|
+
externalSort
|
|
308
|
+
onSortingChange={(s) => controller.setSort(s)}
|
|
309
|
+
filterable
|
|
310
|
+
showGlobalFilter
|
|
311
|
+
externalFilter
|
|
312
|
+
onFiltersChange={(f) =>
|
|
313
|
+
controller.setFilter({
|
|
314
|
+
global: f.global || undefined,
|
|
315
|
+
columns: Object.fromEntries(
|
|
316
|
+
f.columns.map((c) => [c.id, { operator: c.operator, value: c.value, valueTo: c.valueTo, selectedValues: c.selectedValues }]),
|
|
317
|
+
),
|
|
318
|
+
})}
|
|
319
|
+
onRowClick={(e) => (editing = e.row)}
|
|
320
|
+
onRowSelectionChange={(_sel, rows) => (selected = rows)}
|
|
321
|
+
showPagination
|
|
322
|
+
externalPagination
|
|
323
|
+
rowCount={state.total}
|
|
324
|
+
pageIndex={state.pageIndex}
|
|
325
|
+
pageSize={state.pageSize}
|
|
326
|
+
onPaginationChange={({ pageIndex, pageSize }) => {
|
|
327
|
+
if (pageSize !== state.pageSize) controller.setPageSize(pageSize)
|
|
328
|
+
else controller.setPage(pageIndex)
|
|
329
|
+
}}
|
|
330
|
+
emptyMessage="No ${label} yet"
|
|
331
|
+
containerHeight={480}
|
|
332
|
+
/>
|
|
333
|
+
|
|
334
|
+
{#if editing !== undefined}
|
|
335
|
+
<SvGridEditPanel schema={${constName}} row={editing} ${hasLookups ? 'lookups={lookups} ' : ''}onSubmit={save} onCancel={() => (editing = undefined)} />
|
|
336
|
+
{/if}
|
|
337
|
+
</div>
|
|
338
|
+
|
|
339
|
+
<style>
|
|
340
|
+
.page { display: flex; flex-direction: column; gap: 12px; }
|
|
341
|
+
.page__toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
342
|
+
.page__title { margin: 0; font-size: 18px; font-weight: 600; }
|
|
343
|
+
.page__actions { display: flex; gap: 8px; }
|
|
344
|
+
.page__actions button { padding: 7px 12px; border: 1px solid #ccc; border-radius: 6px; background: #fff; cursor: pointer; }
|
|
345
|
+
.page__error { margin: 0; padding: 8px 12px; color: #b3261e; background: #fdecea; border-radius: 6px; }
|
|
346
|
+
</style>
|
|
347
|
+
`,
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return { files: [schemaFile, serverFile, pageFile] }
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Merge freshly-scaffolded files into existing file contents, replacing only
|
|
355
|
+
* each file's `svgrid:managed` region and preserving everything a user wrote
|
|
356
|
+
* outside it. When there's no existing content (or no markers in it), the new
|
|
357
|
+
* file is used whole. This is what makes regeneration safe.
|
|
358
|
+
*/
|
|
359
|
+
export function mergeManaged(existing: string | null | undefined, generated: string): string {
|
|
360
|
+
if (!existing) return generated
|
|
361
|
+
|
|
362
|
+
const startIdx = existing.indexOf(MANAGED_START)
|
|
363
|
+
const endIdx = existing.indexOf(MANAGED_END)
|
|
364
|
+
if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) return generated
|
|
365
|
+
|
|
366
|
+
const genStart = generated.indexOf(MANAGED_START)
|
|
367
|
+
const genEnd = generated.indexOf(MANAGED_END)
|
|
368
|
+
if (genStart === -1 || genEnd === -1) return generated
|
|
369
|
+
const newRegion = generated.slice(genStart, genEnd + MANAGED_END.length)
|
|
370
|
+
|
|
371
|
+
return (
|
|
372
|
+
existing.slice(0, startIdx) + newRegion + existing.slice(endIdx + MANAGED_END.length)
|
|
373
|
+
)
|
|
374
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Studio look-and-feel themes: faithful reproductions of SvGrid's built-in
|
|
3
|
+
* design-system presets (shadcn/ui, Tailwind, Material 3, Excel, Fluent 2, ...),
|
|
4
|
+
* the same tokens that ship in `examples/src/themes/*.css` and the website Theme
|
|
5
|
+
* Builder. Each theme carries an explicit light + dark palette; picking one (and
|
|
6
|
+
* a light/dark mode) restyles the WHOLE generated app - shell, grids, charts,
|
|
7
|
+
* KPIs, forms - not just the accent.
|
|
8
|
+
*
|
|
9
|
+
* Pure + node-safe (studio subtree): plain data + helpers, so the same tokens
|
|
10
|
+
* drive the browser preview and the emitted `+layout.svelte` / `app.css`.
|
|
11
|
+
*/
|
|
12
|
+
import type { ProjectTheme } from './project.js'
|
|
13
|
+
|
|
14
|
+
export type ThemeMode = 'light' | 'dark'
|
|
15
|
+
|
|
16
|
+
/** The core palette for one mode. Mirrors the Theme Builder's preset shape. */
|
|
17
|
+
export type ThemePalette = {
|
|
18
|
+
bg: string; fg: string; muted: string; border: string
|
|
19
|
+
headerBg: string; headerFg: string; accent: string
|
|
20
|
+
rowAlt: string; rowHover: string; selectionBg: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type StudioTheme = {
|
|
24
|
+
/** Stable id, stored on `ProjectTheme.preset`. */
|
|
25
|
+
id: string
|
|
26
|
+
/** Display name shown on the swatch. */
|
|
27
|
+
name: string
|
|
28
|
+
/** Control corner rounding (px). */
|
|
29
|
+
radius: number
|
|
30
|
+
/** Font stack applied to the app surface. */
|
|
31
|
+
font: string
|
|
32
|
+
light: ThemePalette
|
|
33
|
+
dark: ThemePalette
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The built-in Studio themes, in gallery order (default first). */
|
|
37
|
+
export const studioThemes: StudioTheme[] = [
|
|
38
|
+
{ id: 'shadcn', name: 'shadcn/ui', radius: 6, font: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
|
|
39
|
+
light: { bg: '#ffffff', fg: '#09090b', muted: '#71717a', border: '#e4e4e7', headerBg: '#fafafa', headerFg: '#71717a', accent: '#18181b', rowAlt: '#ffffff', rowHover: '#f4f4f5', selectionBg: '#f4f4f5' },
|
|
40
|
+
dark: { bg: '#0a0a0a', fg: '#fafafa', muted: '#a1a1aa', border: '#27272a', headerBg: '#18181b', headerFg: '#a1a1aa', accent: '#fafafa', rowAlt: '#0a0a0a', rowHover: '#18181b', selectionBg: '#27272a' } },
|
|
41
|
+
{ id: 'tailwind', name: 'Tailwind', radius: 6, font: 'Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
|
|
42
|
+
light: { bg: '#ffffff', fg: '#0f172a', muted: '#64748b', border: '#e2e8f0', headerBg: '#f8fafc', headerFg: '#0f172a', accent: '#4f46e5', rowAlt: '#ffffff', rowHover: '#f1f5f9', selectionBg: '#e0e7ff' },
|
|
43
|
+
dark: { bg: '#0f172a', fg: '#f8fafc', muted: '#94a3b8', border: '#334155', headerBg: '#1e293b', headerFg: '#f8fafc', accent: '#818cf8', rowAlt: '#0f172a', rowHover: '#1e293b', selectionBg: '#312e81' } },
|
|
44
|
+
{ id: 'material', name: 'Material 3', radius: 8, font: '"Roboto Flex", Roboto, "Segoe UI", system-ui, sans-serif',
|
|
45
|
+
light: { bg: '#fef7ff', fg: '#1d1b20', muted: '#49454f', border: '#cac4d0', headerBg: '#f3edf7', headerFg: '#1d1b20', accent: '#6750a4', rowAlt: '#fffbfe', rowHover: '#ece6f0', selectionBg: '#e8def8' },
|
|
46
|
+
dark: { bg: '#141218', fg: '#e6e0e9', muted: '#cac4d0', border: '#49454f', headerBg: '#211f26', headerFg: '#e6e0e9', accent: '#d0bcff', rowAlt: '#141218', rowHover: '#2b2930', selectionBg: '#4a4458' } },
|
|
47
|
+
{ id: 'excel', name: 'Excel', radius: 0, font: '"Aptos", "Segoe UI", Calibri, Arial, sans-serif',
|
|
48
|
+
light: { bg: '#ffffff', fg: '#323130', muted: '#605e5c', border: '#d2d0ce', headerBg: '#f3f2f1', headerFg: '#444444', accent: '#107c41', rowAlt: '#ffffff', rowHover: '#edebe9', selectionBg: 'rgba(16, 124, 65, 0.10)' },
|
|
49
|
+
dark: { bg: '#1b1a19', fg: '#f3f2f1', muted: '#c8c6c4', border: '#3b3a39', headerBg: '#252423', headerFg: '#c8c6c4', accent: '#58c188', rowAlt: '#1b1a19', rowHover: '#323130', selectionBg: 'rgba(88, 193, 136, 0.16)' } },
|
|
50
|
+
{ id: 'fluent', name: 'Fluent 2', radius: 4, font: '"Segoe UI Variable", "Segoe UI", system-ui, sans-serif',
|
|
51
|
+
light: { bg: '#ffffff', fg: '#242424', muted: '#616161', border: '#e0e0e0', headerBg: '#f5f5f5', headerFg: '#242424', accent: '#0f6cbd', rowAlt: '#fafafa', rowHover: '#f0f0f0', selectionBg: '#ebebeb' },
|
|
52
|
+
dark: { bg: '#1f1f1f', fg: '#ffffff', muted: '#d6d6d6', border: '#666666', headerBg: '#141414', headerFg: '#ffffff', accent: '#479ef5', rowAlt: '#1a1a1a', rowHover: '#3d3d3d', selectionBg: '#383838' } },
|
|
53
|
+
{ id: 'carbon', name: 'Carbon', radius: 0, font: '"IBM Plex Sans", "Helvetica Neue", Arial, sans-serif',
|
|
54
|
+
light: { bg: '#ffffff', fg: '#161616', muted: '#525252', border: '#e0e0e0', headerBg: '#e0e0e0', headerFg: '#161616', accent: '#0f62fe', rowAlt: '#f4f4f4', rowHover: '#e8e8e8', selectionBg: '#d0e2ff' },
|
|
55
|
+
dark: { bg: '#161616', fg: '#f4f4f4', muted: '#c6c6c6', border: '#393939', headerBg: '#393939', headerFg: '#f4f4f4', accent: '#78a9ff', rowAlt: '#262626', rowHover: '#333333', selectionBg: '#393939' } },
|
|
56
|
+
{ id: 'sap', name: 'SAP Fiori', radius: 6, font: '"72", "72full", "Arial", sans-serif',
|
|
57
|
+
light: { bg: '#f5f6f7', fg: '#131e29', muted: '#556b82', border: '#e5e5e5', headerBg: '#ffffff', headerFg: '#131e29', accent: '#0070f2', rowAlt: '#f5f6f7', rowHover: '#eaecee', selectionBg: '#ebf8ff' },
|
|
58
|
+
dark: { bg: '#12171c', fg: '#f5f6f7', muted: '#8396a8', border: '#2e3742', headerBg: '#1d232a', headerFg: '#f5f6f7', accent: '#4db1ff', rowAlt: '#12171c', rowHover: '#222b35', selectionBg: '#1d2d3e' } },
|
|
59
|
+
{ id: 'salesforce', name: 'Salesforce', radius: 4, font: '"Salesforce Sans", "SF Pro", Helvetica, Arial, sans-serif',
|
|
60
|
+
light: { bg: '#ffffff', fg: '#181818', muted: '#757575', border: '#c9c9c9', headerBg: '#f3f3f3', headerFg: '#181818', accent: '#0176d3', rowAlt: '#ffffff', rowHover: '#f3f3f3', selectionBg: '#ecebea' },
|
|
61
|
+
dark: { bg: '#181818', fg: '#ffffff', muted: '#c9c9c9', border: '#3e3e3c', headerBg: '#2e2e2e', headerFg: '#ffffff', accent: '#1b96ff', rowAlt: '#181818', rowHover: '#2e2e2e', selectionBg: '#1e4d8e' } },
|
|
62
|
+
{ id: 'atlassian', name: 'Atlassian', radius: 3, font: '"Charlie Display", "Atlassian Sans", -apple-system, "Segoe UI", sans-serif',
|
|
63
|
+
light: { bg: '#ffffff', fg: '#172b4d', muted: '#44546f', border: '#dcdfe4', headerBg: '#fafbfc', headerFg: '#44546f', accent: '#0c66e4', rowAlt: '#ffffff', rowHover: '#f1f2f4', selectionBg: '#e9f2ff' },
|
|
64
|
+
dark: { bg: '#1d2125', fg: '#b6c2cf', muted: '#8590a2', border: '#38414a', headerBg: '#22272b', headerFg: '#8590a2', accent: '#579dff', rowAlt: '#1d2125', rowHover: '#2c333a', selectionBg: '#09326c' } },
|
|
65
|
+
{ id: 'github', name: 'GitHub', radius: 6, font: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif',
|
|
66
|
+
light: { bg: '#ffffff', fg: '#1f2328', muted: '#59636e', border: '#d1d9e0', headerBg: '#f6f8fa', headerFg: '#59636e', accent: '#0969da', rowAlt: '#ffffff', rowHover: '#f6f8fa', selectionBg: '#ddf4ff' },
|
|
67
|
+
dark: { bg: '#0d1117', fg: '#f0f6fc', muted: '#9198a1', border: '#3d444d', headerBg: '#151b23', headerFg: '#9198a1', accent: '#4493f8', rowAlt: '#0d1117', rowHover: '#151b23', selectionBg: 'rgba(56, 139, 253, 0.10)' } },
|
|
68
|
+
{ id: 'antd', name: 'Ant Design', radius: 6, font: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", sans-serif',
|
|
69
|
+
light: { bg: '#ffffff', fg: 'rgba(0, 0, 0, 0.88)', muted: 'rgba(0, 0, 0, 0.65)', border: '#d9d9d9', headerBg: '#fafafa', headerFg: 'rgba(0, 0, 0, 0.88)', accent: '#1677ff', rowAlt: '#ffffff', rowHover: '#f5f5f5', selectionBg: '#e6f4ff' },
|
|
70
|
+
dark: { bg: '#141414', fg: 'rgba(255, 255, 255, 0.85)', muted: 'rgba(255, 255, 255, 0.65)', border: '#424242', headerBg: '#1f1f1f', headerFg: 'rgba(255, 255, 255, 0.85)', accent: '#177ddc', rowAlt: '#141414', rowHover: '#262626', selectionBg: '#111a2c' } },
|
|
71
|
+
{ id: 'ag-alpine', name: 'Alpine', radius: 3, font: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
72
|
+
light: { bg: '#ffffff', fg: '#181d1f', muted: 'rgba(24, 29, 31, 0.5)', border: '#babfc7', headerBg: '#f8f8f8', headerFg: '#181d1f', accent: '#2196f3', rowAlt: '#fcfcfc', rowHover: 'rgba(33, 150, 243, 0.1)', selectionBg: 'rgba(33, 150, 243, 0.3)' },
|
|
73
|
+
dark: { bg: '#181d1f', fg: '#ffffff', muted: 'rgba(255, 255, 255, 0.5)', border: '#68686e', headerBg: '#222628', headerFg: '#ffffff', accent: '#2196f3', rowAlt: '#222628', rowHover: 'rgba(33, 150, 243, 0.1)', selectionBg: 'rgba(33, 150, 243, 0.3)' } },
|
|
74
|
+
{ id: 'bootstrap', name: 'Bootstrap 5', radius: 6, font: 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
|
75
|
+
light: { bg: '#ffffff', fg: '#212529', muted: '#6c757d', border: '#dee2e6', headerBg: '#f8f9fa', headerFg: '#212529', accent: '#0d6efd', rowAlt: '#fcfcfd', rowHover: '#f2f2f2', selectionBg: '#cfe2ff' },
|
|
76
|
+
dark: { bg: '#212529', fg: '#dee2e6', muted: '#adb5bd', border: '#495057', headerBg: '#2b3035', headerFg: '#dee2e6', accent: '#0d6efd', rowAlt: '#25292e', rowHover: '#2c3034', selectionBg: '#031633' } },
|
|
77
|
+
{ id: 'vercel', name: 'Vercel', radius: 6, font: '"Geist", "Geist Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
78
|
+
light: { bg: '#ffffff', fg: '#000000', muted: '#666666', border: '#eaeaea', headerBg: '#fafafa', headerFg: '#666666', accent: '#0070f3', rowAlt: '#fafafa', rowHover: '#f5f5f5', selectionBg: '#e8f0fe' },
|
|
79
|
+
dark: { bg: '#000000', fg: '#ffffff', muted: '#888888', border: '#333333', headerBg: '#111111', headerFg: '#888888', accent: '#3291ff', rowAlt: '#0a0a0a', rowHover: '#1a1a1a', selectionBg: '#0d2847' } },
|
|
80
|
+
{ id: 'linear', name: 'Linear', radius: 6, font: '"Inter Variable", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
|
81
|
+
light: { bg: '#ffffff', fg: '#282a30', muted: '#6b6f76', border: '#e9e9eb', headerBg: '#f7f8f8', headerFg: '#6b6f76', accent: '#5e6ad2', rowAlt: '#fbfbfc', rowHover: '#f4f4f5', selectionBg: '#e8eafd' },
|
|
82
|
+
dark: { bg: '#08090a', fg: '#f7f8f8', muted: '#8a8f98', border: '#23252a', headerBg: '#0f1011', headerFg: '#8a8f98', accent: '#5e6ad2', rowAlt: '#0f1011', rowHover: '#1a1b1e', selectionBg: '#2a2d4a' } },
|
|
83
|
+
{ id: 'notion', name: 'Notion', radius: 4, font: 'ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
|
|
84
|
+
light: { bg: '#ffffff', fg: '#37352f', muted: '#787774', border: '#e9e9e7', headerBg: '#f7f6f3', headerFg: '#787774', accent: '#2383e2', rowAlt: '#fbfbfa', rowHover: '#f1f1ef', selectionBg: '#d9eaf7' },
|
|
85
|
+
dark: { bg: '#191919', fg: '#d4d4d4', muted: '#9b9b9b', border: '#2f2f2f', headerBg: '#202020', headerFg: '#9b9b9b', accent: '#529cca', rowAlt: '#1c1c1c', rowHover: '#252525', selectionBg: '#20415c' } },
|
|
86
|
+
{ id: 'nord', name: 'Nord', radius: 4, font: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
87
|
+
light: { bg: '#eceff4', fg: '#2e3440', muted: '#4c566a', border: '#d8dee9', headerBg: '#e5e9f0', headerFg: '#2e3440', accent: '#5e81ac', rowAlt: '#e5e9f0', rowHover: '#dde3ec', selectionBg: '#d8dee9' },
|
|
88
|
+
dark: { bg: '#2e3440', fg: '#d8dee9', muted: '#4c566a', border: '#434c5e', headerBg: '#3b4252', headerFg: '#e5e9f0', accent: '#88c0d0', rowAlt: '#333b48', rowHover: '#3b4252', selectionBg: '#434c5e' } },
|
|
89
|
+
{ id: 'dracula', name: 'Dracula', radius: 6, font: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
90
|
+
light: { bg: '#fffbeb', fg: '#1f1f1f', muted: '#6c664b', border: '#dedccf', headerBg: '#ece9df', headerFg: '#1f1f1f', accent: '#644ac9', rowAlt: '#fffdf5', rowHover: '#f2efe3', selectionBg: '#cfcfde' },
|
|
91
|
+
dark: { bg: '#282a36', fg: '#f8f8f2', muted: '#6272a4', border: '#44475a', headerBg: '#21222c', headerFg: '#f8f8f2', accent: '#bd93f9', rowAlt: '#2d2f3b', rowHover: '#343746', selectionBg: '#44475a' } },
|
|
92
|
+
{ id: 'catppuccin', name: 'Catppuccin', radius: 8, font: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
93
|
+
light: { bg: '#eff1f5', fg: '#4c4f69', muted: '#6c6f85', border: '#ccd0da', headerBg: '#e6e9ef', headerFg: '#4c4f69', accent: '#8839ef', rowAlt: '#e6e9ef', rowHover: '#dce0e8', selectionBg: '#dce0e8' },
|
|
94
|
+
dark: { bg: '#1e1e2e', fg: '#cdd6f4', muted: '#a6adc8', border: '#313244', headerBg: '#181825', headerFg: '#cdd6f4', accent: '#cba6f7', rowAlt: '#1a1a28', rowHover: '#313244', selectionBg: '#45475a' } },
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
/** The default theme (used when a project has no `preset`). */
|
|
98
|
+
export const defaultStudioTheme: StudioTheme = studioThemes.find((t) => t.id === 'tailwind') ?? studioThemes[0]!
|
|
99
|
+
|
|
100
|
+
/** Look up a theme by id. */
|
|
101
|
+
export const getStudioTheme = (id?: string): StudioTheme | undefined =>
|
|
102
|
+
id ? studioThemes.find((t) => t.id === id) : undefined
|
|
103
|
+
|
|
104
|
+
/** A readable (near-black / white) foreground for text placed on the accent. */
|
|
105
|
+
function onAccent(accent: string): string {
|
|
106
|
+
const m = /^#?([0-9a-f]{6})$/i.exec(accent.trim())
|
|
107
|
+
if (!m) return '#ffffff'
|
|
108
|
+
const n = parseInt(m[1]!, 16)
|
|
109
|
+
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255
|
|
110
|
+
// Perceived luminance (sRGB, quick approximation).
|
|
111
|
+
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
|
112
|
+
return lum > 0.6 ? '#18181b' : '#ffffff'
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Resolve the effective `--sg-*` tokens for a project theme: the chosen preset
|
|
116
|
+
* (default if none) in the chosen mode, with the accent override applied. */
|
|
117
|
+
export function resolveThemeTokens(theme?: ProjectTheme): Record<string, string> {
|
|
118
|
+
const preset = getStudioTheme(theme?.preset) ?? defaultStudioTheme
|
|
119
|
+
const mode: ThemeMode = theme?.mode === 'dark' ? 'dark' : 'light'
|
|
120
|
+
const p = preset[mode]
|
|
121
|
+
const accent = theme?.accent || p.accent
|
|
122
|
+
return {
|
|
123
|
+
'--sg-bg': p.bg,
|
|
124
|
+
'--sg-fg': p.fg,
|
|
125
|
+
'--sg-muted': p.muted,
|
|
126
|
+
'--sg-border': p.border,
|
|
127
|
+
'--sg-header-bg': p.headerBg,
|
|
128
|
+
'--sg-header-fg': p.headerFg,
|
|
129
|
+
// A subtle inset surface (used by e.g. the master-detail region behind the
|
|
130
|
+
// nested grid). Themed so it isn't a fixed light grey on dark presets.
|
|
131
|
+
'--sg-bg-subtle': p.headerBg,
|
|
132
|
+
'--sg-row-alt-bg': p.rowAlt,
|
|
133
|
+
'--sg-row-hover-bg': p.rowHover,
|
|
134
|
+
'--sg-selection-bg': p.selectionBg,
|
|
135
|
+
// Inputs/checkboxes: pin to the theme's own surface so they never borrow the
|
|
136
|
+
// host page's input tokens (which is how a themed grid ended up with dark
|
|
137
|
+
// checkboxes on a light page when the host mode differed).
|
|
138
|
+
'--sg-input-bg': p.bg,
|
|
139
|
+
'--sg-input-border': p.border,
|
|
140
|
+
'--sg-accent': accent,
|
|
141
|
+
'--sg-on-accent': onAccent(accent),
|
|
142
|
+
'--sg-radius': `${preset.radius}px`,
|
|
143
|
+
'--sg-font': preset.font,
|
|
144
|
+
// The grid's custom scrollbar is a shadow-DOM web component that reads its
|
|
145
|
+
// OWN --sg-scrollbar-* tokens (with fixed light-mode fallbacks). Without
|
|
146
|
+
// these it ignores the theme entirely - a light scrollbar left on a dark
|
|
147
|
+
// grid. CSS custom properties pierce shadow DOM, so defining them here (on
|
|
148
|
+
// the themed container) reaches the scrollbar. Derive them from the palette
|
|
149
|
+
// so the scrollbar tracks the preset + light/dark mode like everything else.
|
|
150
|
+
'--sg-scrollbar-bg': p.headerBg,
|
|
151
|
+
'--sg-scrollbar-border': p.border,
|
|
152
|
+
'--sg-scrollbar-thumb': p.muted,
|
|
153
|
+
'--sg-scrollbar-thumb-hover': p.fg,
|
|
154
|
+
'--sg-scrollbar-thumb-active': p.fg,
|
|
155
|
+
'--sg-scrollbar-arrow': p.muted,
|
|
156
|
+
'--sg-scrollbar-arrow-hover': p.fg,
|
|
157
|
+
'--sg-scrollbar-arrow-hover-bg': p.rowHover,
|
|
158
|
+
'--sg-scrollbar-arrow-active': p.fg,
|
|
159
|
+
'--sg-scrollbar-arrow-active-bg': p.selectionBg,
|
|
160
|
+
'--sg-scrollbar-arrow-disabled': p.border,
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** True when the project's theme is rendering in dark mode. */
|
|
165
|
+
export const isDarkTheme = (theme?: ProjectTheme): boolean => theme?.mode === 'dark'
|
|
166
|
+
|
|
167
|
+
/** An inline `style` string (`--sg-x: y; ...; color-scheme; font-family`) for previews. */
|
|
168
|
+
export function themeStyleString(theme?: ProjectTheme): string {
|
|
169
|
+
const tokens = resolveThemeTokens(theme)
|
|
170
|
+
const vars = Object.entries(tokens).map(([k, v]) => `${k}: ${v}`).join('; ')
|
|
171
|
+
return `${vars}; color-scheme: ${isDarkTheme(theme) ? 'dark' : 'light'}; font-family: ${tokens['--sg-font']}`
|
|
172
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { EntitySchema } from '../schema'
|
|
3
|
+
import { scaffold } from './scaffold'
|
|
4
|
+
import { summarizeVerify, verifyScaffold } from './verify'
|
|
5
|
+
|
|
6
|
+
const schema: EntitySchema = {
|
|
7
|
+
name: 'customers',
|
|
8
|
+
idField: 'id',
|
|
9
|
+
fields: [
|
|
10
|
+
{ field: 'id', type: 'text', primaryKey: true },
|
|
11
|
+
{ field: 'name', type: 'text' },
|
|
12
|
+
],
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('verifyScaffold', () => {
|
|
16
|
+
it('reports ok when the scaffolded Svelte page compiles', async () => {
|
|
17
|
+
const { files } = scaffold(schema)
|
|
18
|
+
const res = await verifyScaffold(files)
|
|
19
|
+
expect(res.ok).toBe(true)
|
|
20
|
+
expect(res.checked).toBeGreaterThan(0) // the .svelte page was compiled for real
|
|
21
|
+
expect(res.issues.filter((i) => i.severity === 'error')).toHaveLength(0)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('flags a malformed Svelte file with an error', async () => {
|
|
25
|
+
const res = await verifyScaffold([
|
|
26
|
+
{ path: 'Bad.svelte', description: '', contents: '{#if true}<p>oops' },
|
|
27
|
+
])
|
|
28
|
+
expect(res.ok).toBe(false)
|
|
29
|
+
expect(res.issues.some((i) => i.severity === 'error')).toBe(true)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('skips non-svelte files (they need the project tsc)', async () => {
|
|
33
|
+
const res = await verifyScaffold([{ path: 'x.ts', description: '', contents: 'export const a = 1' }])
|
|
34
|
+
expect(res.skipped).toBe(1)
|
|
35
|
+
expect(res.checked).toBe(0)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('summarizeVerify', () => {
|
|
40
|
+
it('summarizes ok, failure, and skipped', () => {
|
|
41
|
+
expect(summarizeVerify({ ok: true, issues: [], checked: 2, skipped: 1 })).toMatch(/verified: 2/)
|
|
42
|
+
expect(
|
|
43
|
+
summarizeVerify({ ok: false, checked: 1, skipped: 0, issues: [{ path: 'p.svelte', severity: 'error', message: 'boom' }] }),
|
|
44
|
+
).toMatch(/VERIFICATION FAILED/)
|
|
45
|
+
expect(summarizeVerify({ ok: true, issues: [], checked: 0, skipped: 2 })).toMatch(/skipped/)
|
|
46
|
+
})
|
|
47
|
+
})
|