@svgrid/enterprise 2.6.0 → 2.6.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.
@@ -159,15 +159,15 @@ function blockMarkup(entity, schemaVar, typeName, block, resolve, ctx = { hasRec
159
159
  if (cfg.scheduler) {
160
160
  const schDisp = relationDisplayFields(ctx.rawEntity ?? entity, resolve);
161
161
  const schAsText = (f) => schDisp.get(f) ?? f;
162
- return ` <div ${span}${cls}>
163
- <SvGrid
164
- data={allRows}
165
- columns={${colVar}}
166
- getRowId={(r) => String((r as Record<string, unknown>)[idField])}
167
- loading={!allRowsReady}
168
- scheduler={${schedulerConfigExpr(cfg.scheduler, typeName, schAsText)}}
169
- containerHeight=${ctx.pane ? '"100%"' : `{${block.height ?? 640}}`}
170
- />
162
+ return ` <div ${span}${cls}>
163
+ <SvGrid
164
+ data={allRows}
165
+ columns={${colVar}}
166
+ getRowId={(r) => String((r as Record<string, unknown>)[idField])}
167
+ loading={!allRowsReady}
168
+ scheduler={${schedulerConfigExpr(cfg.scheduler, typeName, schAsText)}}
169
+ containerHeight=${ctx.pane ? '"100%"' : `{${block.height ?? 640}}`}
170
+ />
171
171
  </div>`;
172
172
  }
173
173
  // Grouped + tree grids both load the full dataset and render client-side (a grouped
@@ -311,22 +311,22 @@ function blockMarkup(entity, schemaVar, typeName, block, resolve, ctx = { hasRec
311
311
  }
312
312
  // No-code export toolbar - buttons wired to the grid's own export API.
313
313
  const exportBar = gridHasExport(cfg) && ctx.captureApi ? exportToolbarMarkup(cfg.export, ctx.captureApi, entity.name) : '';
314
- return ` <div ${span}${cls}>
315
- ${exportBar} <SvGrid
316
- ${lines.join('\n ')}
317
- />
314
+ return ` <div ${span}${cls}>
315
+ ${exportBar} <SvGrid
316
+ ${lines.join('\n ')}
317
+ />
318
318
  </div>`;
319
319
  }
320
320
  case 'chart': {
321
321
  const drillRoute = cfg.drillScreen && ctx.routeById?.get(cfg.drillScreen);
322
322
  const onDrill = drillRoute ? ` onDrill={(cat) => goto('/${drillRoute}?${cfg.dimension}=' + encodeURIComponent(String(cat)))}` : '';
323
- return ` <div ${span}${cls}>
324
- <SvSchemaChart schema={${schemaVar}} rows={${rowsExpr}} dimension="${cfg.dimension}"${cfg.measure ? ` measure="${cfg.measure}"` : ''} reduce="${cfg.reduce}" type="${cfg.type}"${block.height ? ` height={${block.height}}` : ''} controls={false} accent="${chartColorExpr(cfg.color)}"${cfg.dataLabels === false ? ' dataLabels={false}' : ''}${onDrill} />
323
+ return ` <div ${span}${cls}>
324
+ <SvSchemaChart schema={${schemaVar}} rows={${rowsExpr}} dimension="${cfg.dimension}"${cfg.measure ? ` measure="${cfg.measure}"` : ''} reduce="${cfg.reduce}" type="${cfg.type}"${block.height ? ` height={${block.height}}` : ''} controls={false} accent="${chartColorExpr(cfg.color)}"${cfg.dataLabels === false ? ' dataLabels={false}' : ''}${onDrill} />
325
325
  </div>`;
326
326
  }
327
327
  case 'dashboard':
328
- return ` <div ${span}${cls}>
329
- <SvSchemaDashboard schema={${schemaVar}} rows={${rowsExpr}} />
328
+ return ` <div ${span}${cls}>
329
+ <SvSchemaDashboard schema={${schemaVar}} rows={${rowsExpr}} />
330
330
  </div>`;
331
331
  case 'kpi': {
332
332
  const measurePart = cfg.measure ? `measure: '${cfg.measure}', ` : '';
@@ -354,9 +354,9 @@ ${exportBar} <SvGrid
354
354
  const deltaChip = cfg.target == null
355
355
  ? `\n {@const _d = seriesDelta(_s)}\n {#if _d != null}<span class="kpi__delta" class:is-up={_d >= 0} class:is-down={_d < 0}>{_d >= 0 ? '▲' : '▼'} {Math.abs(_d).toFixed(0)}%</span>{/if}`
356
356
  : '';
357
- rows.push(` {#if ${seriesExpr}.length > 1}
358
- {@const _s = ${seriesExpr}}${deltaChip}
359
- <svg class="kpi__spark" viewBox="0 0 120 30" preserveAspectRatio="none" aria-hidden="true"><polyline points={sparklinePoints(_s)} fill="none" stroke="currentColor" stroke-width="1.5" vector-effect="non-scaling-stroke" /></svg>
357
+ rows.push(` {#if ${seriesExpr}.length > 1}
358
+ {@const _s = ${seriesExpr}}${deltaChip}
359
+ <svg class="kpi__spark" viewBox="0 0 120 30" preserveAspectRatio="none" aria-hidden="true"><polyline points={sparklinePoints(_s)} fill="none" stroke="currentColor" stroke-width="1.5" vector-effect="non-scaling-stroke" /></svg>
360
360
  {/if}`);
361
361
  }
362
362
  return ` <div ${span}${wrapperClass(block, 'kpi')}>\n${rows.join('\n')}\n </div>`;
@@ -364,9 +364,9 @@ ${exportBar} <SvGrid
364
364
  case 'gauge': {
365
365
  const gexpr = `reduceValue(${rowsExpr}, { ${cfg.measure ? `measure: '${cfg.measure}', ` : ''}reduce: '${cfg.reduce}' })`;
366
366
  const unit = cfg.unit ? ` unit=${JSON.stringify(cfg.unit)}` : '';
367
- return ` <div ${span}${wrapperClass(block, 'gaugecard')}>
368
- <span class="kpi__label">${tLabel(cfg.label, block.id)}</span>
369
- <SvGauge value={${gexpr}} min={${cfg.min}} max={${cfg.max}}${unit} size={172} />
367
+ return ` <div ${span}${wrapperClass(block, 'gaugecard')}>
368
+ <span class="kpi__label">${tLabel(cfg.label, block.id)}</span>
369
+ <SvGauge value={${gexpr}} min={${cfg.min}} max={${cfg.max}}${unit} size={172} />
370
370
  </div>`;
371
371
  }
372
372
  case 'tree': {
@@ -374,8 +374,8 @@ ${exportBar} <SvGrid
374
374
  return ` <div ${span}${cls}><!-- tree: set a label field + a self-referential parent field in the inspector --></div>`;
375
375
  }
376
376
  const idExpr = `${schemaVar}.idField ?? 'id'`;
377
- return ` <div ${span}${wrapperClass(block, 'treecard')}>
378
- <SvTree nodes={toTreeNodes(${rowsExpr} as Record<string, unknown>[], ${idExpr}, ${JSON.stringify(cfg.labelField)}, ${JSON.stringify(cfg.parentField)})} />
377
+ return ` <div ${span}${wrapperClass(block, 'treecard')}>
378
+ <SvTree nodes={toTreeNodes(${rowsExpr} as Record<string, unknown>[], ${idExpr}, ${JSON.stringify(cfg.labelField)}, ${JSON.stringify(cfg.parentField)})} />
379
379
  </div>`;
380
380
  }
381
381
  case 'tabs': {
@@ -388,19 +388,19 @@ ${exportBar} <SvGrid
388
388
  const panels = cfg.tabs
389
389
  .map((t, i) => {
390
390
  const children = t.blocks.map((cb) => blockMarkup(entity, schemaVar, typeName, cb, resolve, ctx)).filter(Boolean).join('\n');
391
- return ` {#if id === '${tabId(block.id, i)}'}
392
- <div class="st-screen">
393
- ${children || ' <p style="color: var(--sg-muted, #94a3b8); font-size: 13px; padding: 10px;">This tab is empty.</p>'}
394
- </div>
391
+ return ` {#if id === '${tabId(block.id, i)}'}
392
+ <div class="st-screen">
393
+ ${children || ' <p style="color: var(--sg-muted, #94a3b8); font-size: 13px; padding: 10px;">This tab is empty.</p>'}
394
+ </div>
395
395
  {/if}`;
396
396
  })
397
397
  .join('\n');
398
- return ` <div ${span}${cls}>
399
- <SvTabs tabs={${items}} value={${tabsVar}} onChange={(id) => (${tabsVar} = id)}>
400
- {#snippet panel(id)}
401
- ${panels}
402
- {/snippet}
403
- </SvTabs>
398
+ return ` <div ${span}${cls}>
399
+ <SvTabs tabs={${items}} value={${tabsVar}} onChange={(id) => (${tabsVar} = id)}>
400
+ {#snippet panel(id)}
401
+ ${panels}
402
+ {/snippet}
403
+ </SvTabs>
404
404
  </div>`;
405
405
  }
406
406
  case 'accordion': {
@@ -411,19 +411,19 @@ ${panels}
411
411
  const panels = cfg.sections
412
412
  .map((s, i) => {
413
413
  const children = s.blocks.map((cb) => blockMarkup(entity, schemaVar, typeName, cb, resolve, ctx)).filter(Boolean).join('\n');
414
- return ` {#if item.id === '${accSectionId(block.id, i)}'}
415
- <div class="st-screen">
416
- ${children || ' <p style="color: var(--sg-muted, #94a3b8); font-size: 13px; padding: 10px;">This section is empty.</p>'}
417
- </div>
414
+ return ` {#if item.id === '${accSectionId(block.id, i)}'}
415
+ <div class="st-screen">
416
+ ${children || ' <p style="color: var(--sg-muted, #94a3b8); font-size: 13px; padding: 10px;">This section is empty.</p>'}
417
+ </div>
418
418
  {/if}`;
419
419
  })
420
420
  .join('\n');
421
- return ` <div ${span}${cls}>
422
- <SvAccordion items={${items}} expandMode="${cfg.multiple ? 'multiple' : 'single'}" expanded={${accVar}} onChange={(ids) => (${accVar} = ids)}>
423
- {#snippet panel(item)}
424
- ${panels}
425
- {/snippet}
426
- </SvAccordion>
421
+ return ` <div ${span}${cls}>
422
+ <SvAccordion items={${items}} expandMode="${cfg.multiple ? 'multiple' : 'single'}" expanded={${accVar}} onChange={(ids) => (${accVar} = ids)}>
423
+ {#snippet panel(item)}
424
+ ${panels}
425
+ {/snippet}
426
+ </SvAccordion>
427
427
  </div>`;
428
428
  }
429
429
  case 'master-detail': {
@@ -437,14 +437,14 @@ ${panels}
437
437
  // of expanding inline - the detail page shows the same children as a timeline.
438
438
  const mdRoute = cfg.linkScreen ? ctx.routeById?.get(cfg.linkScreen) : undefined;
439
439
  const onParent = mdRoute ? ` onParentClick={(id) => goto('/${mdRoute}?id=' + encodeURIComponent(id))}` : '';
440
- return ` <div ${span}${cls}>
441
- <SvGridMasterDetail schema={${schemaVar}} data={allRows} detailSchema={${cn.schemaVar}} getChildren={(p) => ${childRows}.filter((c) => String((c as Record<string, unknown>)['${cfg.foreignKey}']) === String((p as Record<string, unknown>)[${schemaVar}.idField ?? 'id']))} rowHeight={30}${onParent}${block.height ? ` containerHeight={${block.height}}` : ''} />
440
+ return ` <div ${span}${cls}>
441
+ <SvGridMasterDetail schema={${schemaVar}} data={allRows} detailSchema={${cn.schemaVar}} getChildren={(p) => ${childRows}.filter((c) => String((c as Record<string, unknown>)['${cfg.foreignKey}']) === String((p as Record<string, unknown>)[${schemaVar}.idField ?? 'id']))} rowHeight={30}${onParent}${block.height ? ` containerHeight={${block.height}}` : ''} />
442
442
  </div>`;
443
443
  }
444
444
  case 'pivot': {
445
445
  const h = block.height ?? 460;
446
- return ` <div ${wrapperStyle(block, `height: ${h}px`)}${cls}>
447
- <SvPivotDesigner data={${rowsExpr}} fields={${pivotFieldsExpr(entity)}} layout={${pivotLayoutExpr(cfg)}} />
446
+ return ` <div ${wrapperStyle(block, `height: ${h}px`)}${cls}>
447
+ <SvPivotDesigner data={${rowsExpr}} fields={${pivotFieldsExpr(entity)}} layout={${pivotLayoutExpr(cfg)}} />
448
448
  </div>`;
449
449
  }
450
450
  case 'filter':
@@ -466,8 +466,8 @@ ${panels}
466
466
  const openRoute = cfg.openScreen ? ctx.routeById?.get(cfg.openScreen) : undefined;
467
467
  const onOpen = openRoute ? ` onOpen={(id) => goto('/${openRoute}?id=' + encodeURIComponent(id))}` : '';
468
468
  // Dragging a card updates its groupBy value in the local row state (optimistic).
469
- return ` <div ${wrapperStyle(block)}${cls}>
470
- <SvBoard schema={${schemaVar}} rows={allRows} loading={!allRowsReady} groupBy=${JSON.stringify(cfg.groupBy)} titleField=${JSON.stringify(asText(cfg.titleField))}${badge}${sub}${onOpen} height={${h}} onMove={(id, value) => { allRows = allRows.map((r) => String((r as Record<string, unknown>)[idField]) === String(id) ? ({ ...r, ['${cfg.groupBy}']: value }) : r) }} />
469
+ return ` <div ${wrapperStyle(block)}${cls}>
470
+ <SvBoard schema={${schemaVar}} rows={allRows} loading={!allRowsReady} groupBy=${JSON.stringify(cfg.groupBy)} titleField=${JSON.stringify(asText(cfg.titleField))}${badge}${sub}${onOpen} height={${h}} onMove={(id, value) => { allRows = allRows.map((r) => String((r as Record<string, unknown>)[idField]) === String(id) ? ({ ...r, ['${cfg.groupBy}']: value }) : r) }} />
471
471
  </div>`;
472
472
  }
473
473
  case 'calendar': {
@@ -481,8 +481,8 @@ ${panels}
481
481
  // + recurrence): a richer view of the grid's rows with a detail drawer. `openScreen`
482
482
  // navigation is superseded by the in-place drawer.
483
483
  const sc = { startField: cfg.dateField, titleField: cfg.titleField, colorField: cfg.colorField, drawer: true };
484
- return ` <div ${wrapperStyle(block)}${cls}>
485
- <SvGrid data={allRows} columns={schemaToColumns(${schemaVar})} getRowId={(r) => String((r as Record<string, unknown>)[${schemaVar}.idField ?? 'id'])} loading={!allRowsReady} scheduler={${schedulerConfigExpr(sc, typeName, asText)}} containerHeight={${h}} />
484
+ return ` <div ${wrapperStyle(block)}${cls}>
485
+ <SvGrid data={allRows} columns={schemaToColumns(${schemaVar})} getRowId={(r) => String((r as Record<string, unknown>)[${schemaVar}.idField ?? 'id'])} loading={!allRowsReady} scheduler={${schedulerConfigExpr(sc, typeName, asText)}} containerHeight={${h}} />
486
486
  </div>`;
487
487
  }
488
488
  case 'detail': {
@@ -530,8 +530,8 @@ ${panels}
530
530
  props.push(`selectedId={page.url.searchParams.get('id') ?? undefined}`);
531
531
  if (h)
532
532
  props.push(`height={${h}}`);
533
- return ` <div ${wrapperStyle(block)}${cls}>
534
- <SvRecordDetail ${props.join(' ')} />
533
+ return ` <div ${wrapperStyle(block)}${cls}>
534
+ <SvRecordDetail ${props.join(' ')} />
535
535
  </div>`;
536
536
  }
537
537
  case 'lookup':
@@ -539,10 +539,40 @@ ${panels}
539
539
  case 'component':
540
540
  return componentBlockMarkup(block, cfg, ctx.handleNames?.get(block.id), rowsExpr);
541
541
  case 'form':
542
+ return createFormMarkup(entity, schemaVar, block, cfg);
542
543
  default:
543
- return ''; // the form is the edit modal, rendered after the screen grid
544
+ return '';
544
545
  }
545
546
  }
547
+ /**
548
+ * A standalone create form: blank, always on the page, submits a new row.
549
+ *
550
+ * Keyed on `formSaves` so a successful create remounts the panel with empty
551
+ * values - the panel seeds itself from `row` once, so without the key the
552
+ * previous entry would still be sitting in the fields.
553
+ */
554
+ function createFormMarkup(entity, schemaVar, block, cfg) {
555
+ const span = wrapperStyle(block);
556
+ const cls = wrapperClass(block);
557
+ const attrs = [`schema={${schemaVar}}`, 'row={null}', 'presentation="inline"'];
558
+ attrs.push(`title={${jsStr(cfg.title ?? `New ${entity.label ?? entity.name}`)}}`);
559
+ if (cfg.submitLabel)
560
+ attrs.push(`submitLabel={${jsStr(cfg.submitLabel)}}`);
561
+ // Inline fills its block unless told otherwise, so only a real choice is emitted.
562
+ if (cfg.width && cfg.width !== 'md')
563
+ attrs.push(`formSize=${jsStr(cfg.width)}`);
564
+ // A confirmation only makes sense when the page stays put; navigating away
565
+ // makes the new screen the confirmation.
566
+ const done = cfg.afterSave === 'navigate'
567
+ ? ''
568
+ : `
569
+ {#if formSaves}<p class="st-form__done" role="status">Saved. Add another below.</p>{/if}`;
570
+ return ` <div ${span}${cls}>${done}
571
+ {#key formSaves}
572
+ <SvGridEditPanel ${attrs.join(' ')} onSubmit={createRecord} />
573
+ {/key}
574
+ </div>`;
575
+ }
546
576
  /** Emits a UI-kit component block (see `UI_COMPONENT_REGISTRY`): a literal
547
577
  * `<SvXxx .../>` tag carrying its configured "chrome" props + optional text
548
578
  * content. Entity-agnostic - used both from `blockMarkup` (mixed onto an
@@ -584,9 +614,9 @@ function componentBlockMarkup(block, cfg, handleName, rowsExpr) {
584
614
  const wrapper = STANDARD_UI_EVENTS.filter((se) => !propWired.some((e) => e.key === se.key))
585
615
  .map((se) => ` on${se.dom}={(e) => ${handleName}.fire('${se.key}', e)}`)
586
616
  .join('');
587
- return ` <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
588
- <div id="${block.id}"${wrapper} ${span}${cls}>
589
- <${spec.importName} {...${handleName}.props}${eventAttrs}${inner}
617
+ return ` <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
618
+ <div id="${block.id}"${wrapper} ${span}${cls}>
619
+ <${spec.importName} {...${handleName}.props}${eventAttrs}${inner}
590
620
  </div>`;
591
621
  }
592
622
  // Data bindings: a bound prop's value is a reactive expression over the screen's
@@ -622,8 +652,8 @@ function componentBlockMarkup(block, cfg, handleName, rowsExpr) {
622
652
  : contentB
623
653
  ? `>{${bindingExpr(contentB, rowsExpr, false)}}</${spec.importName}>`
624
654
  : `>{${jsStr(String(cfg.props._content ?? spec.contentDefault ?? ''))}}</${spec.importName}>`;
625
- return ` <div id="${block.id}" ${span}${cls}>
626
- ${openTag}${inner}
655
+ return ` <div id="${block.id}" ${span}${cls}>
656
+ ${openTag}${inner}
627
657
  </div>`;
628
658
  }
629
659
  /** A JS expression for a component prop's configured value, by panel type. */
@@ -758,12 +788,12 @@ function filterPanelState(entity, block, cfg) {
758
788
  return ` if (v[${key}]) c[${key}] = { operator: 'equals', value: v[${key}] }`;
759
789
  return ` if (v[${key}]) c[${key}] = { operator: 'contains', value: v[${key}] }`;
760
790
  }).join('\n');
761
- return `let ${state} = $state<Record<string, string>>({})
762
- function ${apply}() {
763
- const v = ${state}
764
- const c: Record<string, { operator: 'equals' | 'contains'; value: string }> = {}
765
- ${assigns}
766
- controller.setFilter({ columns: c })
791
+ return `let ${state} = $state<Record<string, string>>({})
792
+ function ${apply}() {
793
+ const v = ${state}
794
+ const c: Record<string, { operator: 'equals' | 'contains'; value: string }> = {}
795
+ ${assigns}
796
+ controller.setFilter({ columns: c })
767
797
  }`;
768
798
  }
769
799
  /** The faceted filter sidebar markup, wired to its facet state. */
@@ -773,22 +803,22 @@ function filterPanelMarkup(entity, block, cfg) {
773
803
  const set = `${state}[${jsStr(f.field)}] = e.currentTarget.value; ${apply}()`;
774
804
  if (f.type === 'enum') {
775
805
  const opts = enumOpts(f).map((o) => `<option value="${o.value}">${o.label}</option>`).join('');
776
- return ` <label class="st-filter__row"><span>${fieldLabel(f)}</span>
777
- <select onchange={(e) => { ${set} }}><option value="">Any</option>${opts}</select>
806
+ return ` <label class="st-filter__row"><span>${fieldLabel(f)}</span>
807
+ <select onchange={(e) => { ${set} }}><option value="">Any</option>${opts}</select>
778
808
  </label>`;
779
809
  }
780
810
  if (f.type === 'boolean') {
781
- return ` <label class="st-filter__row"><span>${fieldLabel(f)}</span>
782
- <select onchange={(e) => { ${set} }}><option value="">Any</option><option value="true">Yes</option><option value="false">No</option></select>
811
+ return ` <label class="st-filter__row"><span>${fieldLabel(f)}</span>
812
+ <select onchange={(e) => { ${set} }}><option value="">Any</option><option value="true">Yes</option><option value="false">No</option></select>
783
813
  </label>`;
784
814
  }
785
- return ` <label class="st-filter__row"><span>${fieldLabel(f)}</span>
786
- <input type="search" placeholder="Search…" oninput={(e) => { ${set} }} />
815
+ return ` <label class="st-filter__row"><span>${fieldLabel(f)}</span>
816
+ <input type="search" placeholder="Search…" oninput={(e) => { ${set} }} />
787
817
  </label>`;
788
818
  }).join('\n');
789
- return ` <aside ${wrapperStyle(block)}${wrapperClass(block, 'st-filter')}>
790
- <div class="st-filter__title">${cfg.title ?? 'Filters'}</div>
791
- ${controls}
819
+ return ` <aside ${wrapperStyle(block)}${wrapperClass(block, 'st-filter')}>
820
+ <div class="st-filter__title">${cfg.title ?? 'Filters'}</div>
821
+ ${controls}
792
822
  </aside>`;
793
823
  }
794
824
  /** A `{#snippet}` rendering a grid row's action buttons (edit / delete / navigate).
@@ -821,10 +851,10 @@ function rowActionsSnippet(idSafe, typeName, entity, actions, routeById, gate, s
821
851
  const target = a.targetField ?? idField;
822
852
  return `<button type="button" class="st-rowaction" onclick={(e) => { e.stopPropagation(); goto('/${route}?${target}=' + encodeURIComponent(String((row as Record<string, unknown>)[${jsStr(src)}] ?? ''))) }}>${a.label ?? 'Open'}</button>`;
823
853
  }).filter(Boolean).join('\n ');
824
- return `{#snippet rowActions_${idSafe}({ row }: { row: ${typeName} })}
825
- <div class="st-rowactions">
826
- ${buttons}
827
- </div>
854
+ return `{#snippet rowActions_${idSafe}({ row }: { row: ${typeName} })}
855
+ <div class="st-rowactions">
856
+ ${buttons}
857
+ </div>
828
858
  {/snippet}`;
829
859
  }
830
860
  /** Sanitize a user action id into a valid JS identifier suffix. */
@@ -900,38 +930,38 @@ function filterSurfaceProps(cfg) {
900
930
  function treeGridScript(idSafe, typeName, cfg) {
901
931
  const parent = jsStr(cfg.treeData.parentField);
902
932
  const rec = `(r as Record<string, unknown>)`;
903
- return `let treeExpanded_${idSafe} = $state<Record<string, boolean>>({})
904
- function toggleTree_${idSafe}(id: string) { treeExpanded_${idSafe} = { ...treeExpanded_${idSafe}, [id]: !(treeExpanded_${idSafe}[id] ?? true) } }
905
- function treeBuild_${idSafe}() {
906
- const rows = allRows
907
- const idOf = (r: ${typeName}) => String(${rec}[idField] ?? '')
908
- const parentOf = (r: ${typeName}) => { const v = ${rec}[${parent}]; return v == null || v === '' ? null : String(v) }
909
- const present = new Set(rows.map(idOf))
910
- const children = new Map<string | null, ${typeName}[]>()
911
- for (const r of rows) { const p = parentOf(r); const key = p != null && present.has(p) ? p : null; const list = children.get(key) ?? []; list.push(r); children.set(key, list) }
912
- const info = new Map<string, { depth: number; hasChildren: boolean; expanded: boolean }>()
913
- const visible: ${typeName}[] = []
914
- const walk = (parentId: string | null, depth: number) => {
915
- for (const r of children.get(parentId) ?? []) {
916
- const id = idOf(r); const hasChildren = (children.get(id)?.length ?? 0) > 0; const expanded = treeExpanded_${idSafe}[id] ?? true
917
- info.set(id, { depth, hasChildren, expanded }); visible.push(r)
918
- if (hasChildren && expanded) walk(id, depth + 1)
919
- }
920
- }
921
- walk(null, 0)
922
- return { info, visible }
923
- }
933
+ return `let treeExpanded_${idSafe} = $state<Record<string, boolean>>({})
934
+ function toggleTree_${idSafe}(id: string) { treeExpanded_${idSafe} = { ...treeExpanded_${idSafe}, [id]: !(treeExpanded_${idSafe}[id] ?? true) } }
935
+ function treeBuild_${idSafe}() {
936
+ const rows = allRows
937
+ const idOf = (r: ${typeName}) => String(${rec}[idField] ?? '')
938
+ const parentOf = (r: ${typeName}) => { const v = ${rec}[${parent}]; return v == null || v === '' ? null : String(v) }
939
+ const present = new Set(rows.map(idOf))
940
+ const children = new Map<string | null, ${typeName}[]>()
941
+ for (const r of rows) { const p = parentOf(r); const key = p != null && present.has(p) ? p : null; const list = children.get(key) ?? []; list.push(r); children.set(key, list) }
942
+ const info = new Map<string, { depth: number; hasChildren: boolean; expanded: boolean }>()
943
+ const visible: ${typeName}[] = []
944
+ const walk = (parentId: string | null, depth: number) => {
945
+ for (const r of children.get(parentId) ?? []) {
946
+ const id = idOf(r); const hasChildren = (children.get(id)?.length ?? 0) > 0; const expanded = treeExpanded_${idSafe}[id] ?? true
947
+ info.set(id, { depth, hasChildren, expanded }); visible.push(r)
948
+ if (hasChildren && expanded) walk(id, depth + 1)
949
+ }
950
+ }
951
+ walk(null, 0)
952
+ return { info, visible }
953
+ }
924
954
  const tree_${idSafe} = $derived(treeBuild_${idSafe}())`;
925
955
  }
926
956
  /** The `{#snippet}` for a tree grid's label column: indent by depth + an expand toggle. */
927
957
  function treeCellSnippet(idSafe, typeName) {
928
958
  const idExpr = `String((row as Record<string, unknown>)[idField] ?? '')`;
929
- return `{#snippet treeCell_${idSafe}({ value, row }: { value: unknown; row: ${typeName} })}
930
- {@const info = tree_${idSafe}.info.get(${idExpr})}
931
- <span class="st-treecell" style="padding-left: {(info?.depth ?? 0) * 18}px;">
932
- {#if info?.hasChildren}<button type="button" class="st-tree-toggle" aria-label="Toggle" aria-expanded={info.expanded} onclick={() => toggleTree_${idSafe}(${idExpr})}>{info.expanded ? '▾' : '▸'}</button>{:else}<span class="st-tree-spacer"></span>{/if}
933
- <span>{String(value ?? '')}</span>
934
- </span>
959
+ return `{#snippet treeCell_${idSafe}({ value, row }: { value: unknown; row: ${typeName} })}
960
+ {@const info = tree_${idSafe}.info.get(${idExpr})}
961
+ <span class="st-treecell" style="padding-left: {(info?.depth ?? 0) * 18}px;">
962
+ {#if info?.hasChildren}<button type="button" class="st-tree-toggle" aria-label="Toggle" aria-expanded={info.expanded} onclick={() => toggleTree_${idSafe}(${idExpr})}>{info.expanded ? '▾' : '▸'}</button>{:else}<span class="st-tree-spacer"></span>{/if}
963
+ <span>{String(value ?? '')}</span>
964
+ </span>
935
965
  {/snippet}`;
936
966
  }
937
967
  /** The export toolbar markup for a grid: buttons wired to the captured grid API var. */
@@ -957,36 +987,36 @@ function exportToolbarMarkup(e, apiVar, entityName) {
957
987
  }
958
988
  /** Shared helper: map a status-ish value to a badge intent by common vocabulary.
959
989
  * Emitted once per page when any column uses the `badge` cell renderer. */
960
- const BADGE_VARIANT_HELPER = ` function stBadgeVariant(value: unknown): 'neutral' | 'success' | 'warning' | 'danger' | 'info' {
961
- const v = String(value ?? '').toLowerCase().trim()
962
- if (['active', 'open', 'success', 'done', 'paid', 'approved', 'complete', 'completed', 'won', 'shipped', 'yes', 'true'].includes(v)) return 'success'
963
- if (['pending', 'warning', 'in progress', 'processing', 'trial', 'review', 'on hold', 'medium'].includes(v)) return 'warning'
964
- if (['closed', 'error', 'failed', 'cancelled', 'canceled', 'overdue', 'rejected', 'lost', 'blocked', 'inactive', 'no', 'false', 'high', 'urgent'].includes(v)) return 'danger'
965
- if (['new', 'info', 'draft', 'low'].includes(v)) return 'info'
966
- return 'neutral'
990
+ const BADGE_VARIANT_HELPER = ` function stBadgeVariant(value: unknown): 'neutral' | 'success' | 'warning' | 'danger' | 'info' {
991
+ const v = String(value ?? '').toLowerCase().trim()
992
+ if (['active', 'open', 'success', 'done', 'paid', 'approved', 'complete', 'completed', 'won', 'shipped', 'yes', 'true'].includes(v)) return 'success'
993
+ if (['pending', 'warning', 'in progress', 'processing', 'trial', 'review', 'on hold', 'medium'].includes(v)) return 'warning'
994
+ if (['closed', 'error', 'failed', 'cancelled', 'canceled', 'overdue', 'rejected', 'lost', 'blocked', 'inactive', 'no', 'false', 'high', 'urgent'].includes(v)) return 'danger'
995
+ if (['new', 'info', 'draft', 'low'].includes(v)) return 'info'
996
+ return 'neutral'
967
997
  }`;
968
998
  /** Shared helper: run an `@svgrid/enterprise` export off a captured grid API.
969
999
  * Emitted once per page when any grid has an xlsx / pdf button. Surfaces the
970
1000
  * failure instead of swallowing it - a missing optional peer dep (jszip for
971
1001
  * xlsx, pdfmake for pdf) is the usual cause and is worth seeing. */
972
- const EXPORT_HELPER = ` async function stExport(api: SvGridApi<never, never> | undefined, format: 'xlsx' | 'pdf', filename: string) {
973
- if (!api) return
974
- try {
975
- await exportGrid(api, { format, filename })
976
- } catch (err) {
977
- console.error('Export failed:', err)
978
- alert('Export failed: ' + (err instanceof Error ? err.message : String(err)))
979
- }
1002
+ const EXPORT_HELPER = ` async function stExport(api: SvGridApi<never, never> | undefined, format: 'xlsx' | 'pdf', filename: string) {
1003
+ if (!api) return
1004
+ try {
1005
+ await exportGrid(api, { format, filename })
1006
+ } catch (err) {
1007
+ console.error('Export failed:', err)
1008
+ alert('Export failed: ' + (err instanceof Error ? err.message : String(err)))
1009
+ }
980
1010
  }`;
981
1011
  /** Shared helper: paginated print off a captured grid API. */
982
- const PRINT_HELPER = ` async function stPrint(api: SvGridApi<never, never> | undefined, title: string) {
983
- if (!api) return
984
- try {
985
- await printGrid(api, { title })
986
- } catch (err) {
987
- console.error('Print failed:', err)
988
- alert('Print failed: ' + (err instanceof Error ? err.message : String(err)))
989
- }
1012
+ const PRINT_HELPER = ` async function stPrint(api: SvGridApi<never, never> | undefined, title: string) {
1013
+ if (!api) return
1014
+ try {
1015
+ await printGrid(api, { title })
1016
+ } catch (err) {
1017
+ console.error('Print failed:', err)
1018
+ alert('Print failed: ' + (err instanceof Error ? err.message : String(err)))
1019
+ }
990
1020
  }`;
991
1021
  /** The `{#snippet}` body for one rich cell renderer (badge / progress / link). */
992
1022
  function cellRendererSnippet(idSafe, field, cellType) {
@@ -1009,17 +1039,17 @@ function cellRendererSnippet(idSafe, field, cellType) {
1009
1039
  * assumed - swap for the app's own if it has one). */
1010
1040
  function actionHandlerScript(a) {
1011
1041
  const safe = actionIdSafe(a.id);
1012
- return `let actionBusy_${safe} = $state(false)
1013
- async function runAction_${safe}(payload?: Record<string, unknown>) {
1014
- actionBusy_${safe} = true
1015
- try {
1016
- const res = await fetch('/api/actions/${a.id}', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload ?? {}) })
1017
- if (!res.ok) throw new Error(((await res.json().catch(() => ({}))) as { error?: string }).error ?? 'Action failed')
1018
- } catch (err) {
1019
- alert(err instanceof Error ? err.message : 'Action failed')
1020
- } finally {
1021
- actionBusy_${safe} = false
1022
- }
1042
+ return `let actionBusy_${safe} = $state(false)
1043
+ async function runAction_${safe}(payload?: Record<string, unknown>) {
1044
+ actionBusy_${safe} = true
1045
+ try {
1046
+ const res = await fetch('/api/actions/${a.id}', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload ?? {}) })
1047
+ if (!res.ok) throw new Error(((await res.json().catch(() => ({}))) as { error?: string }).error ?? 'Action failed')
1048
+ } catch (err) {
1049
+ alert(err instanceof Error ? err.message : 'Action failed')
1050
+ } finally {
1051
+ actionBusy_${safe} = false
1052
+ }
1023
1053
  }`;
1024
1054
  }
1025
1055
  /** A toolbar action button - RBAC-gated by screen access (not a CRUD `can()`
@@ -1041,15 +1071,15 @@ function actionRouteFile(a, screenId, accessEnabled) {
1041
1071
  return {
1042
1072
  path: `src/routes/api/actions/${a.id}/+server.ts`,
1043
1073
  description: `Stub route for the "${a.label}" action - fill in the actual logic.`,
1044
- contents: `${accessImport}
1045
- export async function POST(event: { request: Request; locals?: Record<string, unknown> }) {${guard}
1046
- const body = await event.request.json().catch(() => ({})) as Record<string, unknown>
1047
- void body // the row id (row actions) or {} (toolbar actions) - use it to look up what to act on
1048
-
1049
- // TODO: your business logic here.
1050
-
1051
- return new Response(JSON.stringify({ ok: true }))
1052
- }
1074
+ contents: `${accessImport}
1075
+ export async function POST(event: { request: Request; locals?: Record<string, unknown> }) {${guard}
1076
+ const body = await event.request.json().catch(() => ({})) as Record<string, unknown>
1077
+ void body // the row id (row actions) or {} (toolbar actions) - use it to look up what to act on
1078
+
1079
+ // TODO: your business logic here.
1080
+
1081
+ return new Response(JSON.stringify({ ok: true }))
1082
+ }
1053
1083
  `,
1054
1084
  };
1055
1085
  }
@@ -1082,14 +1112,14 @@ function recordPanelMarkup(entity, schemaVar, block, cfg) {
1082
1112
  // Modal / drawer float over the page (shown only while a row is selected);
1083
1113
  // inline lives in the block, with a prompt when nothing is selected.
1084
1114
  inner = pres === 'inline'
1085
- ? ` {#if selectedRecord}
1086
- <SvGridEditPanel schema={${schemaVar}} row={selectedRecord} presentation="inline" onSubmit={saveRecord} onCancel={() => (selectedRecord = null)} />
1087
- {:else}
1088
- <p class="st-hint">Select a row to see its details.</p>
1115
+ ? ` {#if selectedRecord}
1116
+ <SvGridEditPanel schema={${schemaVar}} row={selectedRecord} presentation="inline" onSubmit={saveRecord} onCancel={() => (selectedRecord = null)} />
1117
+ {:else}
1118
+ <p class="st-hint">Select a row to see its details.</p>
1089
1119
  {/if}`
1090
- : ` <p class="st-hint">Select a row to open its ${pres === 'drawer' ? 'editor drawer' : 'edit dialog'}.</p>
1091
- {#if selectedRecord}
1092
- <SvGridEditPanel schema={${schemaVar}} row={selectedRecord} presentation="${pres}" onSubmit={saveRecord} onCancel={() => (selectedRecord = null)} />
1120
+ : ` <p class="st-hint">Select a row to open its ${pres === 'drawer' ? 'editor drawer' : 'edit dialog'}.</p>
1121
+ {#if selectedRecord}
1122
+ <SvGridEditPanel schema={${schemaVar}} row={selectedRecord} presentation="${pres}" onSubmit={saveRecord} onCancel={() => (selectedRecord = null)} />
1093
1123
  {/if}`;
1094
1124
  }
1095
1125
  else {
@@ -1098,16 +1128,16 @@ function recordPanelMarkup(entity, schemaVar, block, cfg) {
1098
1128
  ? entity.fields.filter((f) => cfg.fields.includes(f.field))
1099
1129
  : entity.fields.filter((f) => f.field !== pk);
1100
1130
  const rows = chosen.map((f) => ` <div class="st-record__row"><dt>${fieldLabel(f)}</dt><dd>{String((selectedRecord as Record<string, unknown>)[${jsStr(f.field)}] ?? '')}</dd></div>`).join('\n');
1101
- inner = ` {#if selectedRecord}
1102
- <dl class="st-record">
1103
- ${rows}
1104
- </dl>
1105
- {:else}
1106
- <p class="st-hint">Select a row to see its details.</p>
1131
+ inner = ` {#if selectedRecord}
1132
+ <dl class="st-record">
1133
+ ${rows}
1134
+ </dl>
1135
+ {:else}
1136
+ <p class="st-hint">Select a row to see its details.</p>
1107
1137
  {/if}`;
1108
1138
  }
1109
- return ` <div ${span}${wrapperClass(block, 'st-record-card')}>
1110
- ${inner}
1139
+ return ` <div ${span}${wrapperClass(block, 'st-record-card')}>
1140
+ ${inner}
1111
1141
  </div>`;
1112
1142
  }
1113
1143
  /** A freestanding page - no bound entity, so no entity-bound `Block`; it can
@@ -1274,56 +1304,56 @@ export function ctxCompletions(screen) {
1274
1304
  * (so calls never false-error) while keeping useful returns (Promise<string>,
1275
1305
  * boolean, ...). Self-contained: the only external reference is the `Row` param.
1276
1306
  * Powers the in-editor TypeScript service's hover / signature / diagnostics. */
1277
- const GRID_API_SIGNATURES = ` getCellValue(rowIndex: number, columnId: string): unknown
1278
- setCellValue(rowIndex: number, columnId: string, value: unknown): void
1279
- startEditing(rowIndex: number, columnId: string): boolean
1280
- stopEditing(cancel?: boolean): boolean
1281
- selectCells(ranges: ReadonlyArray<readonly [number, number, number, number]>): void
1282
- getSelected(): Array<[number, number, number, number]>
1283
- openChart(): void
1284
- closeChart(): void
1285
- getChartSpec(): unknown
1286
- chartRange(ranges?: ReadonlyArray<readonly [number, number, number, number]>): void
1287
- configureChart(config: { open?: boolean; type?: string; dimension?: string | null; series?: string | null; measure?: string | null; reduce?: 'sum' | 'avg' | 'count'; stacked?: boolean; dataLabels?: boolean; logScale?: boolean; timeAxis?: boolean; valueFormat?: 'number' | 'currency' | 'percent' | 'compact' }): void
1288
- setChartAiHandler(handler: ((prompt: string) => Promise<Record<string, unknown> | null>) | null): void
1289
- addRow(row: Row, position?: 'top' | 'bottom' | number): void
1290
- addRows(rows: ReadonlyArray<Row>, position?: 'top' | 'bottom' | number): void
1291
- removeRow(rowIndex: number): void
1292
- removeRows(rowIndices: ReadonlyArray<number>): void
1293
- applyTransaction(tx: { add?: Row[]; update?: Row[]; remove?: Array<string | Row> }): { added: number; updated: number; removed: number }
1294
- addColumn(column: any, position?: 'left' | 'right' | number): void
1295
- addColumns(columns: ReadonlyArray<any>, position?: 'left' | 'right' | number): void
1296
- removeColumn(columnId: string): void
1297
- setColumnVisible(columnId: string, visible: boolean): void
1298
- isColumnVisible(columnId: string): boolean
1299
- setSort(columnId: string, direction: 'asc' | 'desc' | null): void
1300
- clearSort(): void
1301
- setGroupBy(columnIds: ReadonlyArray<string>): void
1302
- setFilter(columnId: string, filter: any | null): void
1303
- setFacetFilter(columnId: string, values: ReadonlyArray<string> | null): void
1304
- clearFilter(columnId: string): void
1305
- clearAllFilters(): void
1306
- getFilters(): Record<string, { operator: string; value: string; valueTo?: string }>
1307
- getDisplayedRows(): ReadonlyArray<Row>
1308
- getData(): ReadonlyArray<Row>
1309
- getColumns(): ReadonlyArray<{ id: string; header: string; visible: boolean }>
1310
- exportCsv(options?: any): Promise<string>
1311
- exportTsv(options?: any): Promise<string>
1312
- exportJson(options?: any): Promise<string>
1313
- copyToClipboard(options?: any): Promise<string>
1314
- clearRowSelection(): void
1315
- setColumnWidth(columnId: string, width: number): void
1316
- getColumnWidths(): Record<string, number>
1317
- autosizeColumn(columnId: string): void
1318
- autosizeAllColumns(): void
1319
- setColumnPinning(pinning: { left?: string[]; right?: string[] }): void
1320
- getColumnPinning(): { left: string[]; right: string[] }
1321
- setColumnOrder(order: ReadonlyArray<string>): void
1322
- getColumnOrder(): string[]
1323
- setRowExpanded(id: string, expanded: boolean): void
1324
- expandAllGroups(): void
1325
- collapseAllGroups(): void
1326
- undo(): boolean
1307
+ const GRID_API_SIGNATURES = ` getCellValue(rowIndex: number, columnId: string): unknown
1308
+ setCellValue(rowIndex: number, columnId: string, value: unknown): void
1309
+ startEditing(rowIndex: number, columnId: string): boolean
1310
+ stopEditing(cancel?: boolean): boolean
1311
+ selectCells(ranges: ReadonlyArray<readonly [number, number, number, number]>): void
1312
+ getSelected(): Array<[number, number, number, number]>
1313
+ openChart(): void
1314
+ closeChart(): void
1315
+ getChartSpec(): unknown
1316
+ chartRange(ranges?: ReadonlyArray<readonly [number, number, number, number]>): void
1317
+ configureChart(config: { open?: boolean; type?: string; dimension?: string | null; series?: string | null; measure?: string | null; reduce?: 'sum' | 'avg' | 'count'; stacked?: boolean; dataLabels?: boolean; logScale?: boolean; timeAxis?: boolean; valueFormat?: 'number' | 'currency' | 'percent' | 'compact' }): void
1318
+ setChartAiHandler(handler: ((prompt: string) => Promise<Record<string, unknown> | null>) | null): void
1319
+ addRow(row: Row, position?: 'top' | 'bottom' | number): void
1320
+ addRows(rows: ReadonlyArray<Row>, position?: 'top' | 'bottom' | number): void
1321
+ removeRow(rowIndex: number): void
1322
+ removeRows(rowIndices: ReadonlyArray<number>): void
1323
+ applyTransaction(tx: { add?: Row[]; update?: Row[]; remove?: Array<string | Row> }): { added: number; updated: number; removed: number }
1324
+ addColumn(column: any, position?: 'left' | 'right' | number): void
1325
+ addColumns(columns: ReadonlyArray<any>, position?: 'left' | 'right' | number): void
1326
+ removeColumn(columnId: string): void
1327
+ setColumnVisible(columnId: string, visible: boolean): void
1328
+ isColumnVisible(columnId: string): boolean
1329
+ setSort(columnId: string, direction: 'asc' | 'desc' | null): void
1330
+ clearSort(): void
1331
+ setGroupBy(columnIds: ReadonlyArray<string>): void
1332
+ setFilter(columnId: string, filter: any | null): void
1333
+ setFacetFilter(columnId: string, values: ReadonlyArray<string> | null): void
1334
+ clearFilter(columnId: string): void
1335
+ clearAllFilters(): void
1336
+ getFilters(): Record<string, { operator: string; value: string; valueTo?: string }>
1337
+ getDisplayedRows(): ReadonlyArray<Row>
1338
+ getData(): ReadonlyArray<Row>
1339
+ getColumns(): ReadonlyArray<{ id: string; header: string; visible: boolean }>
1340
+ exportCsv(options?: any): Promise<string>
1341
+ exportTsv(options?: any): Promise<string>
1342
+ exportJson(options?: any): Promise<string>
1343
+ copyToClipboard(options?: any): Promise<string>
1344
+ clearRowSelection(): void
1345
+ setColumnWidth(columnId: string, width: number): void
1346
+ getColumnWidths(): Record<string, number>
1347
+ autosizeColumn(columnId: string): void
1348
+ autosizeAllColumns(): void
1349
+ setColumnPinning(pinning: { left?: string[]; right?: string[] }): void
1350
+ getColumnPinning(): { left: string[]; right: string[] }
1351
+ setColumnOrder(order: ReadonlyArray<string>): void
1352
+ getColumnOrder(): string[]
1353
+ setRowExpanded(id: string, expanded: boolean): void
1354
+ expandAllGroups(): void
1355
+ collapseAllGroups(): void
1356
+ undo(): boolean
1327
1357
  redo(): boolean`;
1328
1358
  /** Map an entity field to a TS type for the generated Row interface (mirrors scaffold.ts). */
1329
1359
  function fieldTsType(f) {
@@ -1360,35 +1390,35 @@ export function ctxAmbientDts(screen, entity) {
1360
1390
  members.push(` data: { rows: ${rowName}[]; reload(): void }`);
1361
1391
  members.push(' goto(path: string): void');
1362
1392
  members.push(' params: Record<string, string>');
1363
- return `// Ambient types for the "${screen.title}" screen's code-behind. Regenerated - editor use only.
1364
- ${rowDecl}
1365
-
1366
- interface SvGridApi<Row> {
1367
- ${GRID_API_SIGNATURES}
1368
- }
1369
-
1370
- interface DataHandle<T> {
1371
- /** Feed this block its own rows (overrides the screen dataset until cleared). */
1372
- setData(rows: T[]): void
1373
- /** The rows the block renders: the override if set, else the screen dataset. */
1374
- readonly rows: T[]
1375
- /** Drop the override; the block follows the screen dataset again. */
1376
- clear(): void
1377
- }
1378
-
1379
- interface Handle {
1380
- setText(value: string): void
1381
- setLabel(value: string): void
1382
- set(name: string, value: unknown): void
1383
- get(name: string): unknown
1384
- onClick(fn: (e: Event) => void): void
1385
- onclick: (e: Event) => void
1386
- [key: string]: any
1387
- }
1388
- ${componentTypeDecls ? '\n' + componentTypeDecls + '\n' : ''}
1389
- interface PageContext {
1390
- ${members.join('\n')}
1391
- }
1393
+ return `// Ambient types for the "${screen.title}" screen's code-behind. Regenerated - editor use only.
1394
+ ${rowDecl}
1395
+
1396
+ interface SvGridApi<Row> {
1397
+ ${GRID_API_SIGNATURES}
1398
+ }
1399
+
1400
+ interface DataHandle<T> {
1401
+ /** Feed this block its own rows (overrides the screen dataset until cleared). */
1402
+ setData(rows: T[]): void
1403
+ /** The rows the block renders: the override if set, else the screen dataset. */
1404
+ readonly rows: T[]
1405
+ /** Drop the override; the block follows the screen dataset again. */
1406
+ clear(): void
1407
+ }
1408
+
1409
+ interface Handle {
1410
+ setText(value: string): void
1411
+ setLabel(value: string): void
1412
+ set(name: string, value: unknown): void
1413
+ get(name: string): unknown
1414
+ onClick(fn: (e: Event) => void): void
1415
+ onclick: (e: Event) => void
1416
+ [key: string]: any
1417
+ }
1418
+ ${componentTypeDecls ? '\n' + componentTypeDecls + '\n' : ''}
1419
+ interface PageContext {
1420
+ ${members.join('\n')}
1421
+ }
1392
1422
  `;
1393
1423
  }
1394
1424
  /** A comment manifest of what `ctx` gives onLoad/onDestroy - each handle plus the
@@ -1421,103 +1451,103 @@ function handlesModuleFile() {
1421
1451
  return {
1422
1452
  path: 'src/lib/handles.svelte.ts',
1423
1453
  description: 'Reactive imperative handles for UI components (btn.setLabel, btn.onclick = ...).',
1424
- contents: `// Regenerated by SvGrid Studio.
1425
- /** An imperative, reactive handle over a UI component. In page code you get one
1426
- * per component (e.g. \`button1\`); mutate it and the component updates. */
1427
- export class ComponentHandle {
1428
- props = $state<Record<string, unknown>>({})
1429
- text = $state('')
1430
- on = $state<Record<string, (e: Event) => void>>({})
1431
- constructor(init: { props?: Record<string, unknown>; text?: string }) {
1432
- this.props = { ...(init.props ?? {}) }
1433
- this.text = init.text ?? ''
1434
- }
1435
- set(name: string, value: unknown): this { this.props = { ...this.props, [name]: value }; return this }
1436
- get(name: string): unknown { return this.props[name] }
1437
- setText(value: string): this { this.text = value; return this }
1438
- setLabel(value: string): this { this.text = value; return this }
1439
- onEvent(name: string, fn: (e: Event) => void): this { this.on = { ...this.on, [name.toLowerCase()]: fn }; return this }
1440
- onClick(fn: (e: Event) => void): this { return this.onEvent('click', fn) }
1441
- fire(name: string, e: Event): void { this.on[name.toLowerCase()]?.(e) }
1442
- }
1443
-
1444
- /** An imperative, reactive handle over a data-bound block (chart, KPI, gauge,
1445
- * pivot, ...). By default it mirrors the screen's dataset; \`setData(rows)\` pins
1446
- * an override so page code can feed the block its own rows (a filtered slice, a
1447
- * fetch result), and \`clear()\` returns it to the screen dataset. */
1448
- export class DataHandle<T = Record<string, unknown>> {
1449
- #override = $state<T[] | null>(null)
1450
- #fallback: () => T[]
1451
- constructor(fallback: () => T[]) { this.#fallback = fallback }
1452
- /** The rows the block renders: the override if set, else the screen dataset. */
1453
- get rows(): T[] { return this.#override ?? this.#fallback() }
1454
- /** Feed this block its own rows (overrides the screen dataset until cleared). */
1455
- setData(rows: T[]): void { this.#override = rows }
1456
- /** Drop the override; the block follows the screen dataset again. */
1457
- clear(): void { this.#override = null }
1458
- }
1459
-
1460
- /** A DataHandle whose fallback is the screen dataset getter. */
1461
- export function dataHandle<T>(fallback: () => T[]): DataHandle<T> { return new DataHandle<T>(fallback) }
1462
-
1463
- /** Event subscriptions for the Grid (\`ctx.grid.onCellClick = (e) => {}\`). The keys
1464
- * are lowercased on both store + dispatch so any on<Event> casing round-trips. */
1465
- class GridEvents {
1466
- on: Record<string, (...args: unknown[]) => void> = {}
1467
- fire(name: string, ...args: unknown[]): void { this.on[name.toLowerCase()]?.(...args) }
1468
- }
1469
- /** Wrap the Grid's real SvGridApi so page code gets BOTH its methods
1470
- * (\`ctx.grid.exportCsv()\`) and event subscription (\`ctx.grid.onRowClick = fn\`).
1471
- * The api arrives asynchronously (onApiReady), so it is read lazily via \`getApi\`. */
1472
- export function gridHandle<A extends object>(getApi: () => A | null): A & GridEvents {
1473
- const ev = new GridEvents()
1474
- return new Proxy(ev, {
1475
- get(t, k) {
1476
- if (k in t) { const v = (t as Record<string | symbol, unknown>)[k]; return typeof v === 'function' ? (v as (...a: unknown[]) => unknown).bind(t) : v }
1477
- const api = getApi() as Record<string | symbol, unknown> | null
1478
- if (!api) return undefined
1479
- const v = api[k]
1480
- if (typeof v === 'function') return (v as (...a: unknown[]) => unknown).bind(api)
1481
- // A grid PROP read (ctx.grid.sortable) -> its effective value via getOption.
1482
- if (v === undefined && typeof k === 'string' && !/^on[A-Z]/.test(k) && typeof api.getOption === 'function') return (api.getOption as (key: unknown) => unknown)(k)
1483
- return v
1484
- },
1485
- set(t, k, val) {
1486
- // onRowClick = fn / oncellclick = fn -> subscribe (any casing; keys lowercase).
1487
- if (typeof k === 'string' && /^on[A-Za-z]/.test(k)) { t.on = { ...t.on, [k.slice(2).toLowerCase()]: val as (...a: unknown[]) => void }; return true }
1488
- const api = getApi() as Record<string | symbol, unknown> | null
1489
- // ctx.grid.sortable = true -> the grid's reactive option channel (setOption writes
1490
- // a $state override the grid merges over its props). Falls back to a direct set for
1491
- // an older grid build without setOption.
1492
- if (api && typeof api.setOption === 'function') { (api.setOption as (key: unknown, value: unknown) => void)(k, val); return true }
1493
- if (api) api[k] = val
1494
- return true
1495
- },
1496
- }) as A & GridEvents
1497
- }
1498
-
1499
- /** A handle plus dynamic setX / onX helpers and \`el.onclick = fn\` assignment. */
1500
- export type Handle = ComponentHandle & Record<string, any>
1501
-
1502
- export function handle(init: { props?: Record<string, unknown>; text?: string }): Handle {
1503
- const h = new ComponentHandle(init)
1504
- return new Proxy(h, {
1505
- get(t, k) {
1506
- if (Reflect.has(t, k)) { const v = (t as unknown as Record<string, unknown>)[k as string]; return typeof v === 'function' ? v.bind(t) : v }
1507
- if (typeof k === 'string' && /^set[A-Z]/.test(k)) { const p = k[3]!.toLowerCase() + k.slice(4); return (v: unknown) => t.set(p, v) }
1508
- // onEvent(fn) call form. onEvent lowercases, so onKeyDown maps to the 'keydown' the wrapper fires.
1509
- if (typeof k === 'string' && /^on[A-Z]/.test(k)) { const e = k.slice(2); return (fn: (ev: Event) => void) => t.onEvent(e, fn) }
1510
- return typeof k === 'string' ? t.props[k] : undefined
1511
- },
1512
- set(t, k, v) {
1513
- // el.onKeyDown = fn / el.onclick = fn -> subscribe (onEvent lowercases the key).
1514
- if (typeof k === 'string' && /^on[A-Za-z]/.test(k)) { t.onEvent(k.slice(2), v as (e: Event) => void); return true }
1515
- if (k === 'text') { t.text = v as string; return true } // content, not a prop
1516
- if (typeof k === 'string') t.set(k, v) // checkbox1.checked = true
1517
- return true
1518
- },
1519
- }) as Handle
1520
- }
1454
+ contents: `// Regenerated by SvGrid Studio.
1455
+ /** An imperative, reactive handle over a UI component. In page code you get one
1456
+ * per component (e.g. \`button1\`); mutate it and the component updates. */
1457
+ export class ComponentHandle {
1458
+ props = $state<Record<string, unknown>>({})
1459
+ text = $state('')
1460
+ on = $state<Record<string, (e: Event) => void>>({})
1461
+ constructor(init: { props?: Record<string, unknown>; text?: string }) {
1462
+ this.props = { ...(init.props ?? {}) }
1463
+ this.text = init.text ?? ''
1464
+ }
1465
+ set(name: string, value: unknown): this { this.props = { ...this.props, [name]: value }; return this }
1466
+ get(name: string): unknown { return this.props[name] }
1467
+ setText(value: string): this { this.text = value; return this }
1468
+ setLabel(value: string): this { this.text = value; return this }
1469
+ onEvent(name: string, fn: (e: Event) => void): this { this.on = { ...this.on, [name.toLowerCase()]: fn }; return this }
1470
+ onClick(fn: (e: Event) => void): this { return this.onEvent('click', fn) }
1471
+ fire(name: string, e: Event): void { this.on[name.toLowerCase()]?.(e) }
1472
+ }
1473
+
1474
+ /** An imperative, reactive handle over a data-bound block (chart, KPI, gauge,
1475
+ * pivot, ...). By default it mirrors the screen's dataset; \`setData(rows)\` pins
1476
+ * an override so page code can feed the block its own rows (a filtered slice, a
1477
+ * fetch result), and \`clear()\` returns it to the screen dataset. */
1478
+ export class DataHandle<T = Record<string, unknown>> {
1479
+ #override = $state<T[] | null>(null)
1480
+ #fallback: () => T[]
1481
+ constructor(fallback: () => T[]) { this.#fallback = fallback }
1482
+ /** The rows the block renders: the override if set, else the screen dataset. */
1483
+ get rows(): T[] { return this.#override ?? this.#fallback() }
1484
+ /** Feed this block its own rows (overrides the screen dataset until cleared). */
1485
+ setData(rows: T[]): void { this.#override = rows }
1486
+ /** Drop the override; the block follows the screen dataset again. */
1487
+ clear(): void { this.#override = null }
1488
+ }
1489
+
1490
+ /** A DataHandle whose fallback is the screen dataset getter. */
1491
+ export function dataHandle<T>(fallback: () => T[]): DataHandle<T> { return new DataHandle<T>(fallback) }
1492
+
1493
+ /** Event subscriptions for the Grid (\`ctx.grid.onCellClick = (e) => {}\`). The keys
1494
+ * are lowercased on both store + dispatch so any on<Event> casing round-trips. */
1495
+ class GridEvents {
1496
+ on: Record<string, (...args: unknown[]) => void> = {}
1497
+ fire(name: string, ...args: unknown[]): void { this.on[name.toLowerCase()]?.(...args) }
1498
+ }
1499
+ /** Wrap the Grid's real SvGridApi so page code gets BOTH its methods
1500
+ * (\`ctx.grid.exportCsv()\`) and event subscription (\`ctx.grid.onRowClick = fn\`).
1501
+ * The api arrives asynchronously (onApiReady), so it is read lazily via \`getApi\`. */
1502
+ export function gridHandle<A extends object>(getApi: () => A | null): A & GridEvents {
1503
+ const ev = new GridEvents()
1504
+ return new Proxy(ev, {
1505
+ get(t, k) {
1506
+ if (k in t) { const v = (t as Record<string | symbol, unknown>)[k]; return typeof v === 'function' ? (v as (...a: unknown[]) => unknown).bind(t) : v }
1507
+ const api = getApi() as Record<string | symbol, unknown> | null
1508
+ if (!api) return undefined
1509
+ const v = api[k]
1510
+ if (typeof v === 'function') return (v as (...a: unknown[]) => unknown).bind(api)
1511
+ // A grid PROP read (ctx.grid.sortable) -> its effective value via getOption.
1512
+ if (v === undefined && typeof k === 'string' && !/^on[A-Z]/.test(k) && typeof api.getOption === 'function') return (api.getOption as (key: unknown) => unknown)(k)
1513
+ return v
1514
+ },
1515
+ set(t, k, val) {
1516
+ // onRowClick = fn / oncellclick = fn -> subscribe (any casing; keys lowercase).
1517
+ if (typeof k === 'string' && /^on[A-Za-z]/.test(k)) { t.on = { ...t.on, [k.slice(2).toLowerCase()]: val as (...a: unknown[]) => void }; return true }
1518
+ const api = getApi() as Record<string | symbol, unknown> | null
1519
+ // ctx.grid.sortable = true -> the grid's reactive option channel (setOption writes
1520
+ // a $state override the grid merges over its props). Falls back to a direct set for
1521
+ // an older grid build without setOption.
1522
+ if (api && typeof api.setOption === 'function') { (api.setOption as (key: unknown, value: unknown) => void)(k, val); return true }
1523
+ if (api) api[k] = val
1524
+ return true
1525
+ },
1526
+ }) as A & GridEvents
1527
+ }
1528
+
1529
+ /** A handle plus dynamic setX / onX helpers and \`el.onclick = fn\` assignment. */
1530
+ export type Handle = ComponentHandle & Record<string, any>
1531
+
1532
+ export function handle(init: { props?: Record<string, unknown>; text?: string }): Handle {
1533
+ const h = new ComponentHandle(init)
1534
+ return new Proxy(h, {
1535
+ get(t, k) {
1536
+ if (Reflect.has(t, k)) { const v = (t as unknown as Record<string, unknown>)[k as string]; return typeof v === 'function' ? v.bind(t) : v }
1537
+ if (typeof k === 'string' && /^set[A-Z]/.test(k)) { const p = k[3]!.toLowerCase() + k.slice(4); return (v: unknown) => t.set(p, v) }
1538
+ // onEvent(fn) call form. onEvent lowercases, so onKeyDown maps to the 'keydown' the wrapper fires.
1539
+ if (typeof k === 'string' && /^on[A-Z]/.test(k)) { const e = k.slice(2); return (fn: (ev: Event) => void) => t.onEvent(e, fn) }
1540
+ return typeof k === 'string' ? t.props[k] : undefined
1541
+ },
1542
+ set(t, k, v) {
1543
+ // el.onKeyDown = fn / el.onclick = fn -> subscribe (onEvent lowercases the key).
1544
+ if (typeof k === 'string' && /^on[A-Za-z]/.test(k)) { t.onEvent(k.slice(2), v as (e: Event) => void); return true }
1545
+ if (k === 'text') { t.text = v as string; return true } // content, not a prop
1546
+ if (typeof k === 'string') t.set(k, v) // checkbox1.checked = true
1547
+ return true
1548
+ },
1549
+ }) as Handle
1550
+ }
1521
1551
  `,
1522
1552
  };
1523
1553
  }
@@ -1648,12 +1678,12 @@ function screenContextFile(screen, rowType) {
1648
1678
  return {
1649
1679
  path: `src/routes/${screen.route}/page-context.ts`,
1650
1680
  description: `Typed page context for "${screen.title}" (regenerated).`,
1651
- contents: `// Regenerated by SvGrid Studio. Edits here are overwritten - write code in handlers.ts.
1652
- ${imports}${imports ? '\n\n' : ''}${typeDecls ? typeDecls + '\n\n' : ''}/** What onLoad(ctx) / onDestroy(ctx) give you: the Grid's real API, each block as a
1653
- * handle, the screen dataset, and goto / params. */
1654
- export type PageContext = {
1655
- ${members.join('\n')}
1656
- }
1681
+ contents: `// Regenerated by SvGrid Studio. Edits here are overwritten - write code in handlers.ts.
1682
+ ${imports}${imports ? '\n\n' : ''}${typeDecls ? typeDecls + '\n\n' : ''}/** What onLoad(ctx) / onDestroy(ctx) give you: the Grid's real API, each block as a
1683
+ * handle, the screen dataset, and goto / params. */
1684
+ export type PageContext = {
1685
+ ${members.join('\n')}
1686
+ }
1657
1687
  `,
1658
1688
  };
1659
1689
  }
@@ -1696,9 +1726,9 @@ function compiledMethodBodies(screen) {
1696
1726
  * behavior here. Scaffolded once (userOwned) and never regenerated - the page
1697
1727
  * imports it, never rewrites it. See HANDLERS-DESIGN.md. */
1698
1728
  function screenHandlersFile(screen) {
1699
- const header = `// Your code for the "${screen.title}" screen.
1700
- // SvGrid Studio scaffolds this file once and never overwrites it - it's yours.
1701
- // Design the screen visually in Studio; write its behavior here.
1729
+ const header = `// Your code for the "${screen.title}" screen.
1730
+ // SvGrid Studio scaffolds this file once and never overwrites it - it's yours.
1731
+ // Design the screen visually in Studio; write its behavior here.
1702
1732
  ${screenElementsManifest(screen)}`;
1703
1733
  let body;
1704
1734
  if (screen.handlersSource) {
@@ -1717,18 +1747,18 @@ ${screenElementsManifest(screen)}`;
1717
1747
  const loadInner = loadRaw ? indentBody(loadRaw) : ' // Runs when the page mounts. Reach blocks via ctx.<name>, feed data via ctx.data / ctx.<chart>.setData(rows).';
1718
1748
  const destroyRaw = compiled.onDestroy ?? screen.handlerBodies?.[ON_DESTROY]?.trim();
1719
1749
  const destroyInner = destroyRaw ? indentBody(destroyRaw) : ' // Runs when the page unmounts. Clean up timers, subscriptions, aborts.';
1720
- body = `import type { PageContext } from './page-context'
1721
-
1722
- /** Runs when the page mounts. Reach blocks (ctx.grid.exportCsv(), ctx.chart1.setData(rows),
1723
- * ctx.button1.onclick = () => {}), fetch data, navigate with ctx.goto. */
1724
- export async function ${ON_LOAD}(ctx: PageContext): Promise<void> {
1725
- ${loadInner}
1726
- }
1727
-
1728
- /** Runs when the page unmounts. Clean up anything onLoad started. */
1729
- export function ${ON_DESTROY}(ctx: PageContext): void {
1730
- ${destroyInner}
1731
- }
1750
+ body = `import type { PageContext } from './page-context'
1751
+
1752
+ /** Runs when the page mounts. Reach blocks (ctx.grid.exportCsv(), ctx.chart1.setData(rows),
1753
+ * ctx.button1.onclick = () => {}), fetch data, navigate with ctx.goto. */
1754
+ export async function ${ON_LOAD}(ctx: PageContext): Promise<void> {
1755
+ ${loadInner}
1756
+ }
1757
+
1758
+ /** Runs when the page unmounts. Clean up anything onLoad started. */
1759
+ export function ${ON_DESTROY}(ctx: PageContext): void {
1760
+ ${destroyInner}
1761
+ }
1732
1762
  `;
1733
1763
  }
1734
1764
  return {
@@ -1775,13 +1805,13 @@ function freestandingScreenPage(screen, accessEnabled, i18nEnabled) {
1775
1805
  ? `\n let rows = $state<RowData[]>([])\n let gridApi = $state<SvGridApi<any, any> | null>(null)\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature, rowSelectionFeature })\n const columns = $derived(rows.length ? Object.keys(rows[0]).map((field) => ({ field, header: field })) : [])`
1776
1806
  : '';
1777
1807
  const codeScript = hasCode
1778
- ? `\n ${gridScript ? gridScript.trimStart() + '\n ' : ''}${handleDecls ? handleDecls + '\n ' : ''}onMount(() => {
1779
- // ctx is internal plumbing wired from this screen's blocks; the typed surface your
1780
- // code uses lives in handlers.ts (PageContext). Component handles are runtime proxies,
1781
- // so the cast bridges their dynamic shape to the typed context.
1782
- const ctx = ${wiring.ctxLiteral} as unknown as PageContext
1783
- handlers.${ON_LOAD}(ctx)
1784
- return () => handlers.${ON_DESTROY}(ctx)
1808
+ ? `\n ${gridScript ? gridScript.trimStart() + '\n ' : ''}${handleDecls ? handleDecls + '\n ' : ''}onMount(() => {
1809
+ // ctx is internal plumbing wired from this screen's blocks; the typed surface your
1810
+ // code uses lives in handlers.ts (PageContext). Component handles are runtime proxies,
1811
+ // so the cast bridges their dynamic shape to the typed context.
1812
+ const ctx = ${wiring.ctxLiteral} as unknown as PageContext
1813
+ handlers.${ON_LOAD}(ctx)
1814
+ return () => handlers.${ON_DESTROY}(ctx)
1785
1815
  })`
1786
1816
  : '';
1787
1817
  // Grid events fire into ctx.grid so freestanding page code can subscribe too.
@@ -1795,14 +1825,14 @@ function freestandingScreenPage(screen, accessEnabled, i18nEnabled) {
1795
1825
  return {
1796
1826
  path: `src/routes/${screen.route}/+page.svelte`,
1797
1827
  description: `${screen.title} screen (freestanding, no bound entity).`,
1798
- contents: `<script lang="ts">
1799
- ${gridImport}${typeImport}${handleImport}${codeImport}${gotoImport}${pageStateImport}${accessImport}${i18nImport}${parts.join('\n\n ')}${codeScript}
1800
- </script>
1801
-
1802
- <h1 class="st__title">${title}</h1>
1803
- ${toolbar}<div class="st-screen${screenClassSuffix(screen)}">
1804
- ${content}
1805
- </div>
1828
+ contents: `<script lang="ts">
1829
+ ${gridImport}${typeImport}${handleImport}${codeImport}${gotoImport}${pageStateImport}${accessImport}${i18nImport}${parts.join('\n\n ')}${codeScript}
1830
+ </script>
1831
+
1832
+ <h1 class="st__title">${title}</h1>
1833
+ ${toolbar}<div class="st-screen${screenClassSuffix(screen)}">
1834
+ ${content}
1835
+ </div>
1806
1836
  `,
1807
1837
  };
1808
1838
  }
@@ -1852,7 +1882,10 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
1852
1882
  // literal shared with the PageContext type. Data handles read the entity row type.
1853
1883
  const handleNames = handleNameMap(screen);
1854
1884
  const codeWire = codeEnabled ? codeWiring(screen, n.type, undefined) : null;
1855
- const hasForm = has(blocks, 'form'); // legacy standalone form block
1885
+ // A standalone Form block creates a record. It does NOT want the grid's edit
1886
+ // modal (that edits an existing row), so it is deliberately kept out of
1887
+ // `wantsForm` - it emits its own always-visible panel and its own handler.
1888
+ const createForm = blocks.map((b) => b.config).find((c) => c.kind === 'form');
1856
1889
  // Editing is a Grid property: a grid with editing 'form' opens the edit panel.
1857
1890
  const gridConfigs = blocks.map((b) => b.config).filter((c) => c.kind === 'grid');
1858
1891
  const formGrid = gridConfigs.find((c) => c.editing === 'form');
@@ -1862,7 +1895,7 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
1862
1895
  // Rich cell renderers (badge / progress / link) each emit a `cell` snippet.
1863
1896
  const cellRenderKinds = new Set(gridConfigs.flatMap((c) => c.columns.filter((col) => col.show && col.cellType).map((col) => col.cellType.kind)));
1864
1897
  const hasCellRenderers = cellRenderKinds.size > 0;
1865
- const wantsForm = !!formGrid || hasForm || hasEditAction;
1898
+ const wantsForm = !!formGrid || hasEditAction;
1866
1899
  // An unpaginated grid loads everything (one big page); else its configured size.
1867
1900
  const gridPageSize = gridConfigs[0] ? (gridConfigs[0].paginated !== false ? (gridConfigs[0].pageSize ?? 10) : 1000) : 10;
1868
1901
  const formPres = formGrid?.formPresentation ?? 'modal';
@@ -1875,7 +1908,7 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
1875
1908
  const recordEditable = blocks.some((b) => b.config.kind === 'record' && b.config.editable);
1876
1909
  // Filter panels drive the grid's controller; record panels read the grid's
1877
1910
  // selection - both need the controller even if the grid isn't editable.
1878
- const needsController = hasGrid || wantsForm || hasFilter || hasRecord;
1911
+ const needsController = hasGrid || wantsForm || hasFilter || hasRecord || !!createForm;
1879
1912
  // Supabase Realtime: when the screen's entity is Supabase-backed and opts into
1880
1913
  // live updates, subscribe to Postgres change streams and refresh() the paged
1881
1914
  // grid on any INSERT / UPDATE / DELETE (respects the active sort/filter/page).
@@ -2000,7 +2033,8 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
2000
2033
  (b.config.kind === 'grid' && b.config.rowActions?.some((a) => a.kind === 'navigate' && a.screen && routeById.has(a.screen))) ||
2001
2034
  (b.config.kind === 'chart' && b.config.drillScreen && routeById.has(b.config.drillScreen)) ||
2002
2035
  ((b.config.kind === 'board' || b.config.kind === 'calendar') && b.config.openScreen != null && routeById.has(b.config.openScreen)) ||
2003
- (b.config.kind === 'master-detail' && b.config.linkScreen != null && routeById.has(b.config.linkScreen)));
2036
+ (b.config.kind === 'master-detail' && b.config.linkScreen != null && routeById.has(b.config.linkScreen)) ||
2037
+ (b.config.kind === 'form' && b.config.afterSave === 'navigate' && b.config.navigateTo != null && routeById.has(b.config.navigateTo)));
2004
2038
  const applyUrlFilters = drillEnabled && needsController;
2005
2039
  const filterableFieldNames = schema.fields.filter((f) => !f.primaryKey).map((f) => f.field);
2006
2040
  // RBAC gates the UI only where there's a create/update affordance to gate.
@@ -2020,9 +2054,9 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
2020
2054
  parts.push(actionHandlerScript(a));
2021
2055
  if (needsController) {
2022
2056
  const urlFilter = applyUrlFilters
2023
- ? `\n const sp = page.url.searchParams
2024
- const _cols: Record<string, { operator: 'equals'; value: string }> = {}
2025
- for (const _f of [${filterableFieldNames.map(jsStr).join(', ')}]) { const _v = sp.get(_f); if (_v != null) _cols[_f] = { operator: 'equals', value: _v } }
2057
+ ? `\n const sp = page.url.searchParams
2058
+ const _cols: Record<string, { operator: 'equals'; value: string }> = {}
2059
+ for (const _f of [${filterableFieldNames.map(jsStr).join(', ')}]) { const _v = sp.get(_f); if (_v != null) _cols[_f] = { operator: 'equals', value: _v } }
2026
2060
  if (Object.keys(_cols).length) controller.setFilter({ columns: _cols })`
2027
2061
  : '';
2028
2062
  const ctlBase = `createServerDataSource<${n.type}>(${n.sourceVar}, { pageSize: ${gridPageSize}, optimistic: true, getRowId: (r) => String((r as Record<string, unknown>)[idField]), onChange: (s) => (view = s) })`;
@@ -2032,12 +2066,12 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
2032
2066
  ? `const __ctl = ${ctlBase}\n const controller = { ...__ctl, createRow: async (i: Partial<${n.type}>) => { const r = await __ctl.createRow(i); gridCtx.fire('rowAdded', r); return r }, updateRow: async (id: string, p: Partial<${n.type}>) => { const r = await __ctl.updateRow(id, p); gridCtx.fire('rowUpdated', r); return r }, deleteRow: async (id: string) => { await __ctl.deleteRow(id); gridCtx.fire('rowDeleted', id) } }`
2033
2067
  : `const controller = ${ctlBase}`;
2034
2068
  const rtWiring = rtSupabase
2035
- ? `\n const __rt = createSupabaseRealtime({ client: supabaseClient as unknown as SupabaseRealtimeClientLike, table: ${jsStr(rtTable)}, debounceMs: 250, onChange: () => controller.refresh() })
2069
+ ? `\n const __rt = createSupabaseRealtime({ client: supabaseClient as unknown as SupabaseRealtimeClientLike, table: ${jsStr(rtTable)}, debounceMs: 250, onChange: () => controller.refresh() })
2036
2070
  return () => { __rt.unsubscribe(); controller.dispose() } })`
2037
2071
  : `\n controller.refresh(); return () => controller.dispose() })`;
2038
- parts.push(`const idField = ${n.schemaVar}.idField ?? 'id'
2039
- let view = $state<ServerState<${n.type}>>({ rows: [], total: 0, loading: false, saving: false, error: null, pageIndex: 0, pageSize: ${gridPageSize}, pageCount: 1, sortModel: [], filterModel: {} })
2040
- ${ctlDecl}
2072
+ parts.push(`const idField = ${n.schemaVar}.idField ?? 'id'
2073
+ let view = $state<ServerState<${n.type}>>({ rows: [], total: 0, loading: false, saving: false, error: null, pageIndex: 0, pageSize: ${gridPageSize}, pageCount: 1, sortModel: [], filterModel: {} })
2074
+ ${ctlDecl}
2041
2075
  $effect(() => {${urlFilter}${rtSupabase ? '\n controller.refresh()' : ''}${rtWiring}`);
2042
2076
  }
2043
2077
  const actionSnippets = [];
@@ -2085,11 +2119,11 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
2085
2119
  // SSR read screens get their rows from the server load (real SSR HTML);
2086
2120
  // the SPA path fetches client-side after mount.
2087
2121
  parts.push(ssrData
2088
- ? `const allRows = $derived(data.rows as ${n.type}[])
2122
+ ? `const allRows = $derived(data.rows as ${n.type}[])
2089
2123
  const allRowsReady = true`
2090
- : `let allRows = $state<${n.type}[]>([])
2091
- let allRowsReady = $state(false)
2092
- async function loadAll() { allRows = [...(await ${n.sourceVar}.getRows({ startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} })).rows]; allRowsReady = true }
2124
+ : `let allRows = $state<${n.type}[]>([])
2125
+ let allRowsReady = $state(false)
2126
+ async function loadAll() { allRows = [...(await ${n.sourceVar}.getRows({ startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} })).rows]; allRowsReady = true }
2093
2127
  loadAll()`);
2094
2128
  }
2095
2129
  // Tree-grid state/derivations - emitted after `allRows` so it's in scope.
@@ -2103,13 +2137,13 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
2103
2137
  // Persist the arrangement to localStorage (restored after mount so SSR stays
2104
2138
  // stable), unless the screen opts out - then the seeded layout is fixed.
2105
2139
  const persistParts = panePersist
2106
- ? `
2107
- function loadDock(): DockManagerState | null { try { const r = localStorage.getItem(${dockKey}); return r ? (JSON.parse(r) as DockManagerState) : null } catch { return null } }
2108
- function saveDock(ws: DockManagerState) { try { localStorage.setItem(${dockKey}, JSON.stringify(ws)) } catch { /* storage unavailable */ } }
2140
+ ? `
2141
+ function loadDock(): DockManagerState | null { try { const r = localStorage.getItem(${dockKey}); return r ? (JSON.parse(r) as DockManagerState) : null } catch { return null } }
2142
+ function saveDock(ws: DockManagerState) { try { localStorage.setItem(${dockKey}, JSON.stringify(ws)) } catch { /* storage unavailable */ } }
2109
2143
  $effect(() => { const saved = loadDock(); if (saved) dockWorkspace = saved })`
2110
2144
  : '';
2111
- parts.push(`let dockWorkspace = $state<DockManagerState>(${JSON.stringify(dockState ?? { main: null, floating: [], autoHide: [] })})
2112
- let dockNarrow = $state(false)${persistParts}
2145
+ parts.push(`let dockWorkspace = $state<DockManagerState>(${JSON.stringify(dockState ?? { main: null, floating: [], autoHide: [] })})
2146
+ let dockNarrow = $state(false)${persistParts}
2113
2147
  $effect(() => { const mq = window.matchMedia('(max-width: 720px)'); const sync = () => (dockNarrow = mq.matches); sync(); mq.addEventListener('change', sync); return () => mq.removeEventListener('change', sync) })`);
2114
2148
  }
2115
2149
  for (const c of childList) {
@@ -2119,8 +2153,8 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
2119
2153
  parts.push(`const ${v} = $derived(data.${v} as ${cn.type}[])`);
2120
2154
  continue;
2121
2155
  }
2122
- parts.push(`let ${v} = $state<${cn.type}[]>([])
2123
- async function load_${v}() { ${v} = [...(await ${cn.sourceVar}.getRows({ startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} })).rows] }
2156
+ parts.push(`let ${v} = $state<${cn.type}[]>([])
2157
+ async function load_${v}() { ${v} = [...(await ${cn.sourceVar}.getRows({ startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} })).rows] }
2124
2158
  load_${v}()`);
2125
2159
  }
2126
2160
  // "On record saved" (formSubmit) steps run at the end of a save, with the
@@ -2133,19 +2167,34 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
2133
2167
  const lookupsProp = relationFields.length
2134
2168
  ? `\n const lookups = { ${relationFields.map((f, i) => `${f.field}: ${lookupVars[i]}`).join(', ')} }`
2135
2169
  : '';
2136
- parts.push(`let editing = $state<${n.type} | null | undefined>(undefined)${lookupsProp}
2137
- async function save({ mode, id, values }: { mode: 'create' | 'edit'; id: string | null; values: Partial<${n.type}> }) {
2138
- if (mode === 'create') { await controller.createRow({ [idField]: nextId('${n.idPrefix}'), ...values } as Partial<${n.type}>); controller.setPage(view.pageCount - 1) }
2139
- else if (id) { await controller.updateRow(id, values) }${submitBody}
2140
- editing = undefined${needsAllRows ? '\n await loadAll()' : ''}
2170
+ parts.push(`let editing = $state<${n.type} | null | undefined>(undefined)${lookupsProp}
2171
+ async function save({ mode, id, values }: { mode: 'create' | 'edit'; id: string | null; values: Partial<${n.type}> }) {
2172
+ if (mode === 'create') { await controller.createRow({ [idField]: nextId('${n.idPrefix}'), ...values } as Partial<${n.type}>); controller.setPage(view.pageCount - 1) }
2173
+ else if (id) { await controller.updateRow(id, values) }${submitBody}
2174
+ editing = undefined${needsAllRows ? '\n await loadAll()' : ''}
2175
+ }`);
2176
+ }
2177
+ // Standalone create form: a counter that both blanks the panel (it is the
2178
+ // {#key}) and drives the "Saved" confirmation.
2179
+ if (createForm) {
2180
+ const nav = createForm.afterSave === 'navigate' && createForm.navigateTo
2181
+ ? routeById.get(createForm.navigateTo)
2182
+ : undefined;
2183
+ const after = nav
2184
+ ? `\n await goto('/${nav}')`
2185
+ : '';
2186
+ parts.push(`let formSaves = $state(0)
2187
+ async function createRecord({ values }: { mode: 'create' | 'edit'; id: string | null; values: Partial<${n.type}> }) {
2188
+ await controller.createRow({ [idField]: nextId('${n.idPrefix}'), ...values } as Partial<${n.type}>)${submitBody}
2189
+ formSaves += 1${after}
2141
2190
  }`);
2142
2191
  }
2143
2192
  // Record panel: the row selected in the grid, plus (when editable) a save hook.
2144
2193
  if (hasRecord) {
2145
- parts.push(`let selectedRecord = $state<${n.type} | null>(null)${recordEditable ? `
2146
- async function saveRecord({ id, values }: { mode: 'create' | 'edit'; id: string | null; values: Partial<${n.type}> }) {
2147
- if (id) { await controller.updateRow(id, values) }${submitBody}
2148
- selectedRecord = null${needsAllRows ? '\n await loadAll()' : ''}
2194
+ parts.push(`let selectedRecord = $state<${n.type} | null>(null)${recordEditable ? `
2195
+ async function saveRecord({ id, values }: { mode: 'create' | 'edit'; id: string | null; values: Partial<${n.type}> }) {
2196
+ if (id) { await controller.updateRow(id, values) }${submitBody}
2197
+ selectedRecord = null${needsAllRows ? '\n await loadAll()' : ''}
2149
2198
  }` : ''}`);
2150
2199
  }
2151
2200
  // Filter panel(s): one facet-state object + an apply() that rebuilds the whole
@@ -2169,17 +2218,17 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
2169
2218
  }
2170
2219
  // Tree block: fold the flat rows into SvTree nodes by a self-referential parent.
2171
2220
  if (has(allBlocks, 'tree')) {
2172
- parts.push(`type TreeNode = { id: string; label: string; children: TreeNode[] }
2173
- function toTreeNodes(rows: Record<string, unknown>[], idField: string, labelField: string, parentField: string): TreeNode[] {
2174
- const byId = new Map<string, TreeNode>(rows.map((r) => [String(r[idField]), { id: String(r[idField]), label: String(r[labelField] ?? r[idField]), children: [] }]))
2175
- const roots: TreeNode[] = []
2176
- for (const r of rows) {
2177
- const node = byId.get(String(r[idField]))!
2178
- const pid = r[parentField] != null && r[parentField] !== '' ? String(r[parentField]) : null
2179
- if (pid && pid !== node.id && byId.has(pid)) byId.get(pid)!.children.push(node)
2180
- else roots.push(node)
2181
- }
2182
- return roots
2221
+ parts.push(`type TreeNode = { id: string; label: string; children: TreeNode[] }
2222
+ function toTreeNodes(rows: Record<string, unknown>[], idField: string, labelField: string, parentField: string): TreeNode[] {
2223
+ const byId = new Map<string, TreeNode>(rows.map((r) => [String(r[idField]), { id: String(r[idField]), label: String(r[labelField] ?? r[idField]), children: [] }]))
2224
+ const roots: TreeNode[] = []
2225
+ for (const r of rows) {
2226
+ const node = byId.get(String(r[idField]))!
2227
+ const pid = r[parentField] != null && r[parentField] !== '' ? String(r[parentField]) : null
2228
+ if (pid && pid !== node.id && byId.has(pid)) byId.get(pid)!.children.push(node)
2229
+ else roots.push(node)
2230
+ }
2231
+ return roots
2183
2232
  }`);
2184
2233
  }
2185
2234
  // Code-behind: declare the block handles, then run onLoad on mount + onDestroy on
@@ -2188,10 +2237,10 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
2188
2237
  if (codeWire) {
2189
2238
  if (codeWire.decls.length)
2190
2239
  parts.push(codeWire.decls.join('\n '));
2191
- parts.push(`onMount(() => {
2192
- const ctx = ${codeWire.ctxLiteral} as unknown as PageContext
2193
- handlers.${ON_LOAD}(ctx)
2194
- return () => handlers.${ON_DESTROY}(ctx)
2240
+ parts.push(`onMount(() => {
2241
+ const ctx = ${codeWire.ctxLiteral} as unknown as PageContext
2242
+ handlers.${ON_LOAD}(ctx)
2243
+ return () => handlers.${ON_DESTROY}(ctx)
2195
2244
  })`);
2196
2245
  }
2197
2246
  // --- markup ---
@@ -2222,25 +2271,25 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
2222
2271
  }).filter(Boolean).join('\n')
2223
2272
  : '';
2224
2273
  const screenBody = isDock
2225
- ? `{#if dockNarrow}
2226
- <div class="st-screen${screenClassSuffix(screen)}">
2227
- ${body}
2228
- </div>
2229
- {:else}
2230
- <div class="st-dock">
2231
- <SvDockManager bind:workspace={dockWorkspace}${panePersist ? ' onChange={(w) => saveDock(w)}' : ''}${paneAttrStr}>
2232
- {#snippet pane(p)}
2233
- ${dockPanes}
2234
- {/snippet}
2235
- </SvDockManager>
2236
- </div>
2274
+ ? `{#if dockNarrow}
2275
+ <div class="st-screen${screenClassSuffix(screen)}">
2276
+ ${body}
2277
+ </div>
2278
+ {:else}
2279
+ <div class="st-dock">
2280
+ <SvDockManager bind:workspace={dockWorkspace}${panePersist ? ' onChange={(w) => saveDock(w)}' : ''}${paneAttrStr}>
2281
+ {#snippet pane(p)}
2282
+ ${dockPanes}
2283
+ {/snippet}
2284
+ </SvDockManager>
2285
+ </div>
2237
2286
  {/if}`
2238
2287
  : isCanvas
2239
- ? `<div class="st-canvas${screenClassSuffix(screen)}">
2240
- ${canvasBody}
2288
+ ? `<div class="st-canvas${screenClassSuffix(screen)}">
2289
+ ${canvasBody}
2241
2290
  </div>`
2242
- : `<div class="${screenLayoutOf(screen) === 'stack' ? 'st-stack' : 'st-screen'}${screenClassSuffix(screen)}">
2243
- ${body}
2291
+ : `<div class="${screenLayoutOf(screen) === 'stack' ? 'st-stack' : 'st-screen'}${screenClassSuffix(screen)}">
2292
+ ${body}
2244
2293
  </div>`;
2245
2294
  const modal = wantsForm
2246
2295
  ? `\n\n{#if editing !== undefined}\n <SvGridEditPanel schema={${n.schemaVar}} row={editing}${relationFields.length ? ' {lookups}' : ''} presentation="${formPres}"${formLayoutProps} persistKey="${screen.route}" onSubmit={save} onCancel={() => (editing = undefined)} />\n{/if}`
@@ -2261,37 +2310,37 @@ ${body}
2261
2310
  const title = i18nEnabled ? `{$t('screen.${screen.id}', ${JSON.stringify(screen.title)})}` : screen.title;
2262
2311
  // Surface a failed data load (silent empty grid otherwise) with a retry.
2263
2312
  const errorBanner = needsController
2264
- ? `\n{#if view.error}
2265
- <div class="st-error" role="alert">
2266
- <span>Couldn't load data. {view.error instanceof Error ? view.error.message : String(view.error)}</span>
2267
- <button type="button" class="st-btn" onclick={() => controller.refresh()}>Retry</button>
2268
- </div>
2313
+ ? `\n{#if view.error}
2314
+ <div class="st-error" role="alert">
2315
+ <span>Couldn't load data. {view.error instanceof Error ? view.error.message : String(view.error)}</span>
2316
+ <button type="button" class="st-btn" onclick={() => controller.refresh()}>Retry</button>
2317
+ </div>
2269
2318
  {/if}\n`
2270
2319
  : '';
2271
2320
  return {
2272
2321
  path: `src/routes/${screen.route}/+page.svelte`,
2273
2322
  description: `${screen.title} screen (${blocks.map((b) => b.config.kind).join(', ') || 'empty'}).`,
2274
- contents: `<script lang="ts">
2275
- ${gridImports}${entImport}${connImport}${handleImport}${accessImport}${i18nImport}${gotoImport}${codeImport}${pageImport}${ssrData ? "import type { PageProps } from './$types'\n " : ''}import { ${schemaVarImports.join(', ')}, ${typeImports.map((t) => `type ${t}`).join(', ')} } from '$lib/schemas'
2276
- ${dataImports.length ? ` import { ${dataImports.join(', ')} } from '$lib/data'\n` : ''}
2277
- ${ssrData ? 'let { data }: PageProps = $props()\n\n ' : ''}${parts.join('\n\n ')}
2278
- </script>
2279
-
2280
- <h1 class="st__title">${title}</h1>
2281
- ${errorBanner}
2282
- ${toolbar}${screenBody}${modal}${actionSnippets.length ? '\n\n' + actionSnippets.join('\n\n') : ''}
2323
+ contents: `<script lang="ts">
2324
+ ${gridImports}${entImport}${connImport}${handleImport}${accessImport}${i18nImport}${gotoImport}${codeImport}${pageImport}${ssrData ? "import type { PageProps } from './$types'\n " : ''}import { ${schemaVarImports.join(', ')}, ${typeImports.map((t) => `type ${t}`).join(', ')} } from '$lib/schemas'
2325
+ ${dataImports.length ? ` import { ${dataImports.join(', ')} } from '$lib/data'\n` : ''}
2326
+ ${ssrData ? 'let { data }: PageProps = $props()\n\n ' : ''}${parts.join('\n\n ')}
2327
+ </script>
2328
+
2329
+ <h1 class="st__title">${title}</h1>
2330
+ ${errorBanner}
2331
+ ${toolbar}${screenBody}${modal}${actionSnippets.length ? '\n\n' + actionSnippets.join('\n\n') : ''}
2283
2332
  ${(() => {
2284
- const kpiCss = has(blocks, 'kpi') || has(blocks, 'gauge') || has(blocks, 'tree') ? ` .kpi { position: relative; display: flex; flex-direction: column; gap: 6px; padding: 16px 18px; background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); overflow: hidden; }
2285
- .kpi__head { display: flex; align-items: center; justify-content: space-between; }
2286
- .kpi__label { font-size: 12.5px; font-weight: 600; color: var(--sg-muted, #64748b); text-transform: uppercase; letter-spacing: 0.03em; }
2287
- .kpi__value { font-size: 28px; font-weight: 750; line-height: 1.1; color: var(--sg-fg, #0f172a); }
2288
- .kpi__delta { align-self: flex-start; display: inline-flex; align-items: center; gap: 3px; padding: 2px 8px; border-radius: 999px; font-size: 11.5px; font-weight: 700; background: color-mix(in srgb, var(--sg-muted, #64748b) 14%, transparent); color: var(--sg-muted, #64748b); }
2289
- .kpi__delta.is-up { background: color-mix(in srgb, #16a34a 15%, transparent); color: #16a34a; }
2290
- .kpi__delta.is-down { background: color-mix(in srgb, #dc2626 15%, transparent); color: #dc2626; }
2291
- .kpi__spark { width: 100%; height: 30px; margin-top: 2px; color: var(--sg-accent, #4f46e5); opacity: 0.85; }
2292
- .gaugecard { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 16px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; }
2293
- .gaugecard .kpi__label { align-self: flex-start; }
2294
- .treecard { padding: 12px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; }
2333
+ const kpiCss = has(blocks, 'kpi') || has(blocks, 'gauge') || has(blocks, 'tree') ? ` .kpi { position: relative; display: flex; flex-direction: column; gap: 6px; padding: 16px 18px; background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); overflow: hidden; }
2334
+ .kpi__head { display: flex; align-items: center; justify-content: space-between; }
2335
+ .kpi__label { font-size: 12.5px; font-weight: 600; color: var(--sg-muted, #64748b); text-transform: uppercase; letter-spacing: 0.03em; }
2336
+ .kpi__value { font-size: 28px; font-weight: 750; line-height: 1.1; color: var(--sg-fg, #0f172a); }
2337
+ .kpi__delta { align-self: flex-start; display: inline-flex; align-items: center; gap: 3px; padding: 2px 8px; border-radius: 999px; font-size: 11.5px; font-weight: 700; background: color-mix(in srgb, var(--sg-muted, #64748b) 14%, transparent); color: var(--sg-muted, #64748b); }
2338
+ .kpi__delta.is-up { background: color-mix(in srgb, #16a34a 15%, transparent); color: #16a34a; }
2339
+ .kpi__delta.is-down { background: color-mix(in srgb, #dc2626 15%, transparent); color: #dc2626; }
2340
+ .kpi__spark { width: 100%; height: 30px; margin-top: 2px; color: var(--sg-accent, #4f46e5); opacity: 0.85; }
2341
+ .gaugecard { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 16px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; }
2342
+ .gaugecard .kpi__label { align-self: flex-start; }
2343
+ .treecard { padding: 12px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; }
2295
2344
  ` : '';
2296
2345
  const css = kpiCss + screenLayoutStyle(screen);
2297
2346
  return css ? `\n<style>\n${css}</style>\n` : '';
@@ -2332,19 +2381,19 @@ function ssrReadServerFile(schema, screen, sourceKind, accessEnabled, screenIds,
2332
2381
  return {
2333
2382
  path: `src/routes/${screen.route}/+page.server.ts`,
2334
2383
  description: `${screen.title} - SSR load (full dataset${children.length ? ' + child collections' : ''}).`,
2335
- contents: `import type { PageServerLoad } from './$types'
2336
- import type { ServerRequest } from '@svgrid/grid'
2337
- ${isSql ? "import { createKitDataSource } from '@svgrid/enterprise'\n" : ''}${rbac ? "import { error } from '@sveltejs/kit'\nimport { authorizeAction, getServerRole } from '$lib/access'\n" : ''}${memImports.length ? `import { ${memImports.join(', ')} } from '$lib/data'\n` : ''}
2338
- // This screen renders on the server: real SSR HTML, not just a server-side
2339
- // load. Stated explicitly so it holds whichever way the root layout is set.
2340
- export const ssr = true
2341
- ${rbac ? `\nconst SCREEN_IDS = ${JSON.stringify(screenIds)}\n` : ''}
2342
- const PAGE: ServerRequest = { startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} }
2343
-
2344
- export const load: PageServerLoad = async (${isSql || rbac ? `{ ${[isSql ? 'fetch' : '', rbac ? 'locals' : ''].filter(Boolean).join(', ')} }` : ''}) => {
2345
- ${rbac ? ` if (!authorizeAction(getServerRole({ locals }), 'read', SCREEN_IDS)) throw error(403, 'Not allowed')\n` : ''} const rows = (await ${srcExpr(schema)}.getRows(PAGE)).rows
2346
- ${childLoads ? childLoads + '\n' : ''} return { rows${children.map((c) => `, ${mdChildVar(c.name)}`).join('')} }
2347
- }
2384
+ contents: `import type { PageServerLoad } from './$types'
2385
+ import type { ServerRequest } from '@svgrid/grid'
2386
+ ${isSql ? "import { createKitDataSource } from '@svgrid/enterprise'\n" : ''}${rbac ? "import { error } from '@sveltejs/kit'\nimport { authorizeAction, getServerRole } from '$lib/access'\n" : ''}${memImports.length ? `import { ${memImports.join(', ')} } from '$lib/data'\n` : ''}
2387
+ // This screen renders on the server: real SSR HTML, not just a server-side
2388
+ // load. Stated explicitly so it holds whichever way the root layout is set.
2389
+ export const ssr = true
2390
+ ${rbac ? `\nconst SCREEN_IDS = ${JSON.stringify(screenIds)}\n` : ''}
2391
+ const PAGE: ServerRequest = { startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} }
2392
+
2393
+ export const load: PageServerLoad = async (${isSql || rbac ? `{ ${[isSql ? 'fetch' : '', rbac ? 'locals' : ''].filter(Boolean).join(', ')} }` : ''}) => {
2394
+ ${rbac ? ` if (!authorizeAction(getServerRole({ locals }), 'read', SCREEN_IDS)) throw error(403, 'Not allowed')\n` : ''} const rows = (await ${srcExpr(schema)}.getRows(PAGE)).rows
2395
+ ${childLoads ? childLoads + '\n' : ''} return { rows${children.map((c) => `, ${mdChildVar(c.name)}`).join('')} }
2396
+ }
2348
2397
  `,
2349
2398
  };
2350
2399
  }
@@ -2359,34 +2408,34 @@ ${childLoads ? childLoads + '\n' : ''} return { rows${children.map((c) => `, ${
2359
2408
  // sort/pagination back into the URL. Server-side validation runs in the actions.
2360
2409
  // ---------------------------------------------------------------------------
2361
2410
  /** URL search params -> a data-source request. Shared by every SSR route's load. */
2362
- const SSR_QUERY_HELPER = `// Turn a page URL's search params into a data-source request (sort / filter /
2363
- // page / size). The grid drives these params via goto(), so the server re-runs
2364
- // load() - which makes every list view bookmarkable and functional with no JS.
2365
- import type { ServerRequest } from '@svgrid/grid'
2366
-
2367
- export function planFromSearchParams(url: URL, defaultPageSize = 25): ServerRequest {
2368
- const sp = url.searchParams
2369
- const pageIndex = Math.max(0, Number.parseInt(sp.get('page') ?? '', 10) || 0)
2370
- const pageSize = Math.min(200, Math.max(1, Number.parseInt(sp.get('size') ?? '', 10) || defaultPageSize))
2371
- const sortModel = (sp.get('sort') ?? '')
2372
- .split(',')
2373
- .filter(Boolean)
2374
- .map((token) => {
2375
- const [id, dir] = token.split(':')
2376
- return { id: id!, desc: dir === 'desc' }
2377
- })
2378
- const columns: Record<string, { operator: 'contains'; value: string }> = {}
2379
- for (const [k, v] of sp) if (k.startsWith('f_') && v) columns[k.slice(2)] = { operator: 'contains', value: v }
2380
- const global = sp.get('q') ?? ''
2381
- return {
2382
- startRow: pageIndex * pageSize,
2383
- endRow: pageIndex * pageSize + pageSize,
2384
- pageIndex,
2385
- pageSize,
2386
- sortModel,
2387
- filterModel: { ...(global ? { global } : {}), columns },
2388
- }
2389
- }
2411
+ const SSR_QUERY_HELPER = `// Turn a page URL's search params into a data-source request (sort / filter /
2412
+ // page / size). The grid drives these params via goto(), so the server re-runs
2413
+ // load() - which makes every list view bookmarkable and functional with no JS.
2414
+ import type { ServerRequest } from '@svgrid/grid'
2415
+
2416
+ export function planFromSearchParams(url: URL, defaultPageSize = 25): ServerRequest {
2417
+ const sp = url.searchParams
2418
+ const pageIndex = Math.max(0, Number.parseInt(sp.get('page') ?? '', 10) || 0)
2419
+ const pageSize = Math.min(200, Math.max(1, Number.parseInt(sp.get('size') ?? '', 10) || defaultPageSize))
2420
+ const sortModel = (sp.get('sort') ?? '')
2421
+ .split(',')
2422
+ .filter(Boolean)
2423
+ .map((token) => {
2424
+ const [id, dir] = token.split(':')
2425
+ return { id: id!, desc: dir === 'desc' }
2426
+ })
2427
+ const columns: Record<string, { operator: 'contains'; value: string }> = {}
2428
+ for (const [k, v] of sp) if (k.startsWith('f_') && v) columns[k.slice(2)] = { operator: 'contains', value: v }
2429
+ const global = sp.get('q') ?? ''
2430
+ return {
2431
+ startRow: pageIndex * pageSize,
2432
+ endRow: pageIndex * pageSize + pageSize,
2433
+ pageIndex,
2434
+ pageSize,
2435
+ sortModel,
2436
+ filterModel: { ...(global ? { global } : {}), columns },
2437
+ }
2438
+ }
2390
2439
  `;
2391
2440
  function ssrQueryHelperFile() {
2392
2441
  return { path: 'src/lib/server/query.ts', description: 'SSR: URL search params -> data-source request (sort/filter/page).', contents: SSR_QUERY_HELPER };
@@ -2502,60 +2551,60 @@ function emitSsrGridScreen(schema, screen, sourceKind, accessEnabled, screenIds,
2502
2551
  const localsArg = rbac ? ', locals' : '';
2503
2552
  const readGuard = rbac ? ` if (!authorizeAction(getServerRole({ locals }), 'read', SCREEN_IDS)) throw error(403, 'Not allowed')\n` : '';
2504
2553
  const writeGuard = (action) => (rbac ? ` if (!authorizeAction(getServerRole({ locals }), '${action}', SCREEN_IDS)) return fail(403, { error: 'Not allowed' })\n` : '');
2505
- const server = `import type { Actions, PageServerLoad } from './$types'
2506
- import { fail${rbac ? ', error' : ''} } from '@sveltejs/kit'
2507
- import { ${enterpriseImports} } from '@svgrid/enterprise'
2508
- ${rbac ? "import { authorizeAction, getServerRole } from '../lib/access'\n" : ''}${sourceImport}${schemaImport}
2509
- import { planFromSearchParams } from '../lib/server/query'
2510
-
2511
- // This screen renders on the server: real SSR HTML, not just a server-side
2512
- // load. Stated explicitly so it holds whichever way the root layout is set.
2513
- export const ssr = true
2514
- ${idConst}${rbac ? `\nconst SCREEN_IDS = ${JSON.stringify(screenIds)}` : ''}
2515
- const FIELD_TYPES: Record<string, 'text' | 'number' | 'boolean'> = ${fieldTypesLit}
2516
- ${srcHelper}
2517
- /** Read a submitted form into a typed partial row. Booleans come from checkbox
2518
- * presence; numbers are coerced; empty values are dropped so they don't clobber. */
2519
- function formToValues(fd: FormData): Record<string, unknown> {
2520
- const values: Record<string, unknown> = {}
2521
- for (const [field, type] of Object.entries(FIELD_TYPES)) {
2522
- if (type === 'boolean') { values[field] = fd.get(field) != null; continue }
2523
- const raw = fd.get(field)
2524
- if (raw == null || raw === '') continue
2525
- values[field] = type === 'number' ? Number(raw) : String(raw)
2526
- }
2527
- return values
2528
- }
2529
-
2530
- export const load: PageServerLoad = async ({ url${fetchArg}${localsArg} }) => {
2531
- ${readGuard} const plan = planFromSearchParams(url, ${pageSize})
2532
- const { rows, rowCount } = await ${src}.getRows(plan)
2533
- ${relPrefetch ? relPrefetch + '\n' : ''} return { rows, total: rowCount, page: plan.pageIndex, size: plan.pageSize, sort: plan.sortModel${relReturn} }
2534
- }
2535
-
2536
- export const actions: Actions = {
2537
- create: async ({ request${fetchArg}${localsArg} }) => {
2538
- ${writeGuard('create')} const values = ${readValues('await request.formData()')}
2539
- const errors = await validateAll(${n.schemaVar}, values)
2540
- if (Object.keys(errors).length) return fail(422, { errors, values })
2541
- ${createCall}
2542
- return { ok: true }
2543
- },
2544
- update: async ({ request${fetchArg}${localsArg} }) => {
2545
- const fd = await request.formData()
2546
- ${writeGuard('update')} const id = String(fd.get('__id') ?? '')
2547
- const values = ${readValues('fd')}
2548
- const errors = await validateAll(${n.schemaVar}, values)
2549
- if (Object.keys(errors).length) return fail(422, { errors, values })
2550
- await ${src}.updateRow(id, values)
2551
- return { ok: true }
2552
- },
2553
- delete: async ({ request${fetchArg}${localsArg} }) => {
2554
- ${writeGuard('delete')} const fd = await request.formData()
2555
- await ${src}.deleteRow(String(fd.get('__id') ?? ''))
2556
- return { ok: true }
2557
- },
2558
- }
2554
+ const server = `import type { Actions, PageServerLoad } from './$types'
2555
+ import { fail${rbac ? ', error' : ''} } from '@sveltejs/kit'
2556
+ import { ${enterpriseImports} } from '@svgrid/enterprise'
2557
+ ${rbac ? "import { authorizeAction, getServerRole } from '../lib/access'\n" : ''}${sourceImport}${schemaImport}
2558
+ import { planFromSearchParams } from '../lib/server/query'
2559
+
2560
+ // This screen renders on the server: real SSR HTML, not just a server-side
2561
+ // load. Stated explicitly so it holds whichever way the root layout is set.
2562
+ export const ssr = true
2563
+ ${idConst}${rbac ? `\nconst SCREEN_IDS = ${JSON.stringify(screenIds)}` : ''}
2564
+ const FIELD_TYPES: Record<string, 'text' | 'number' | 'boolean'> = ${fieldTypesLit}
2565
+ ${srcHelper}
2566
+ /** Read a submitted form into a typed partial row. Booleans come from checkbox
2567
+ * presence; numbers are coerced; empty values are dropped so they don't clobber. */
2568
+ function formToValues(fd: FormData): Record<string, unknown> {
2569
+ const values: Record<string, unknown> = {}
2570
+ for (const [field, type] of Object.entries(FIELD_TYPES)) {
2571
+ if (type === 'boolean') { values[field] = fd.get(field) != null; continue }
2572
+ const raw = fd.get(field)
2573
+ if (raw == null || raw === '') continue
2574
+ values[field] = type === 'number' ? Number(raw) : String(raw)
2575
+ }
2576
+ return values
2577
+ }
2578
+
2579
+ export const load: PageServerLoad = async ({ url${fetchArg}${localsArg} }) => {
2580
+ ${readGuard} const plan = planFromSearchParams(url, ${pageSize})
2581
+ const { rows, rowCount } = await ${src}.getRows(plan)
2582
+ ${relPrefetch ? relPrefetch + '\n' : ''} return { rows, total: rowCount, page: plan.pageIndex, size: plan.pageSize, sort: plan.sortModel${relReturn} }
2583
+ }
2584
+
2585
+ export const actions: Actions = {
2586
+ create: async ({ request${fetchArg}${localsArg} }) => {
2587
+ ${writeGuard('create')} const values = ${readValues('await request.formData()')}
2588
+ const errors = await validateAll(${n.schemaVar}, values)
2589
+ if (Object.keys(errors).length) return fail(422, { errors, values })
2590
+ ${createCall}
2591
+ return { ok: true }
2592
+ },
2593
+ update: async ({ request${fetchArg}${localsArg} }) => {
2594
+ const fd = await request.formData()
2595
+ ${writeGuard('update')} const id = String(fd.get('__id') ?? '')
2596
+ const values = ${readValues('fd')}
2597
+ const errors = await validateAll(${n.schemaVar}, values)
2598
+ if (Object.keys(errors).length) return fail(422, { errors, values })
2599
+ await ${src}.updateRow(id, values)
2600
+ return { ok: true }
2601
+ },
2602
+ delete: async ({ request${fetchArg}${localsArg} }) => {
2603
+ ${writeGuard('delete')} const fd = await request.formData()
2604
+ await ${src}.deleteRow(String(fd.get('__id') ?? ''))
2605
+ return { ok: true }
2606
+ },
2607
+ }
2559
2608
  `;
2560
2609
  // The facet panel becomes a plain GET form: each control is named for the
2561
2610
  // param `planFromSearchParams` reads, so submitting navigates to a filtered
@@ -2603,15 +2652,15 @@ ${writeGuard('delete')} const fd = await request.formData()
2603
2652
  return ` <label class="sk-facet">\n <span>${htmlEsc(f.label ?? f.field)}</span>\n ${control}\n </label>`;
2604
2653
  })
2605
2654
  .join('\n');
2606
- return `
2607
- <form method="GET" class="sk-facets">
2608
- <input type="hidden" name="sort" value={page.url.searchParams.get('sort') ?? ''} />
2609
- ${controls}
2610
- <div class="sk-facet-actions">
2611
- <button type="submit">Filter</button>
2612
- <a href={page.url.pathname}>Clear</a>
2613
- </div>
2614
- </form>
2655
+ return `
2656
+ <form method="GET" class="sk-facets">
2657
+ <input type="hidden" name="sort" value={page.url.searchParams.get('sort') ?? ''} />
2658
+ ${controls}
2659
+ <div class="sk-facet-actions">
2660
+ <button type="submit">Filter</button>
2661
+ <a href={page.url.pathname}>Clear</a>
2662
+ </div>
2663
+ </form>
2615
2664
  `;
2616
2665
  })();
2617
2666
  const facetCss = !facetForm
@@ -2664,10 +2713,10 @@ ${controls}
2664
2713
  input = `<input type="${t}" name=${key} value={isCreate ? '' : (${row}[${key}] ?? '')}${req}${attrs(f)} />`;
2665
2714
  }
2666
2715
  const hint = f.input?.help;
2667
- return ` <label class="sk-field${f.input?.span === 2 ? ' sk-field--wide' : ''}">
2668
- <span>${htmlEsc(f.label ?? f.field)}${f.required ? ' *' : ''}</span>
2669
- ${input}
2670
- ${hint ? ` {#if !form?.errors?.[${key}]}<small class="sk-hint">${htmlEsc(hint)}</small>{/if}\n` : ''} {#if form?.errors?.[${key}]}<em class="sk-err">{form.errors[${key}]}</em>{/if}
2716
+ return ` <label class="sk-field${f.input?.span === 2 ? ' sk-field--wide' : ''}">
2717
+ <span>${htmlEsc(f.label ?? f.field)}${f.required ? ' *' : ''}</span>
2718
+ ${input}
2719
+ ${hint ? ` {#if !form?.errors?.[${key}]}<small class="sk-hint">${htmlEsc(hint)}</small>{/if}\n` : ''} {#if form?.errors?.[${key}]}<em class="sk-err">{form.errors[${key}]}</em>{/if}
2671
2720
  </label>`;
2672
2721
  });
2673
2722
  // Group the inputs the way the schema's form layout says, so the server-
@@ -2685,142 +2734,167 @@ ${hint ? ` {#if !form?.errors?.[${key}]}<small class="sk-hint">${htmlEs
2685
2734
  ? (() => {
2686
2735
  const assigned = new Set();
2687
2736
  const groups = layout.sections.map((s) => {
2688
- const items = s.fields.map((name) => { assigned.add(name); return blockFor(name); }).filter(Boolean);
2689
- return { title: s.title, description: s.description, columns: s.columns, items };
2737
+ const names = [];
2738
+ const items = s.fields.map((name) => { assigned.add(name); names.push(name); return blockFor(name); }).filter(Boolean);
2739
+ return { title: s.title, description: s.description, columns: s.columns, collapsible: s.collapsible, collapsed: s.collapsed, names, items };
2690
2740
  }).filter((g) => g.items.length);
2691
2741
  const rest = formFields.filter((f) => !assigned.has(f.field)).map((f) => blockFor(f.field)).filter(Boolean);
2692
- return rest.length ? [...groups, { title: undefined, description: undefined, columns: undefined, items: rest }] : groups;
2742
+ return rest.length ? [...groups, { title: undefined, description: undefined, columns: undefined, collapsible: false, collapsed: false, names: [], items: rest }] : groups;
2693
2743
  })()
2694
2744
  : null;
2695
2745
  const formCols = layout?.columns ?? 1;
2696
2746
  const fieldsMarkup = ssrSections
2697
2747
  ? ssrSections
2698
- .map((g) => ` <fieldset class="sk-group" style="--sk-cols: ${g.columns ?? formCols}">
2699
- ${g.title ? ` <legend>${htmlEsc(g.title)}</legend>\n` : ''}${g.description ? ` <p class="sk-group__desc">${htmlEsc(g.description)}</p>\n` : ''}${g.items.join('\n')}
2700
- </fieldset>`)
2748
+ .map((g) => {
2749
+ const inner = `${g.description ? ` <p class="sk-group__desc">${htmlEsc(g.description)}</p>\n` : ''}${g.items.join('\n')}`;
2750
+ // A collapsible group is a native <details>, so it folds with no JS at
2751
+ // all - which is the point on a server-rendered page. A group that
2752
+ // starts folded is forced open when the server sent back an error for
2753
+ // one of its fields, so a rejected submit is never pointing at
2754
+ // something the user cannot see.
2755
+ if (g.collapsible && g.title) {
2756
+ const open = g.collapsed
2757
+ ? `{${JSON.stringify(g.names)}.some((f) => form?.errors?.[f])}`
2758
+ : '{true}';
2759
+ return ` <details class="sk-group sk-group--fold" style="--sk-cols: ${g.columns ?? formCols}" open=${open}>
2760
+ <summary>${htmlEsc(g.title)}</summary>
2761
+ ${inner}
2762
+ </details>`;
2763
+ }
2764
+ return ` <fieldset class="sk-group" style="--sk-cols: ${g.columns ?? formCols}">
2765
+ ${g.title ? ` <legend>${htmlEsc(g.title)}</legend>\n` : ''}${inner}
2766
+ </fieldset>`;
2767
+ })
2701
2768
  .join('\n')
2702
2769
  : ` <div class="sk-group" style="--sk-cols: ${formCols}">\n${fieldBlocks.join('\n')}\n </div>`;
2703
- const page = `<script lang="ts">
2704
- import { SvGrid, renderSnippet, type ColumnDef, type CellContext } from '@svgrid/grid'
2705
- import { schemaToColumns } from '@svgrid/enterprise'
2706
- import { goto } from '$app/navigation'
2707
- import { page } from '$app/state'
2708
- import { enhance } from '$app/forms'
2709
- import type { SubmitFunction } from '@sveltejs/kit'
2710
- import { ${n.schemaVar}, type ${n.type} } from '$lib/schemas'
2711
- import type { PageProps } from './$types'
2712
-
2713
- let { data, form }: PageProps = $props()
2714
-
2715
- const ID_FIELD = ${jsStr(idField)}
2716
- const TITLE = ${jsStr(screen.title)}
2717
- const NEW_LABEL = ${jsStr('New ' + n.label)}
2718
-
2719
- // Grid columns from the schema + a row-actions column (Edit / Delete).
2720
- const columns: ColumnDef<Record<string, never>, ${n.type}>[] = [
2721
- ...(schemaToColumns(${n.schemaVar}) as ColumnDef<Record<string, never>, ${n.type}>[]),
2722
- { id: '__actions', header: '', sortable: false, cell: (ctx: CellContext<${n.type}>) => renderSnippet(rowActions, { row: ctx.row.original }) },
2723
- ]
2724
-
2725
- // null = no editor; 'create' = new row; a row = editing that row.
2726
- let editing = $state<'create' | ${n.type} | null>(null)
2727
- const isCreate = $derived(editing === 'create')
2728
-
2729
- // Sort / paginate by writing to the URL - load() re-runs on the server.
2730
- function setParams(patch: Record<string, string | null>) {
2731
- const sp = new URLSearchParams(page.url.searchParams)
2732
- for (const [k, v] of Object.entries(patch)) { if (v == null) sp.delete(k); else sp.set(k, v) }
2733
- const q = sp.toString()
2734
- void goto(q ? \`?\${q}\` : page.url.pathname, { keepFocus: true, noScroll: true })
2735
- }
2736
- ${wantsFilter ? `
2737
- // Filter via the URL (q = global search, f_<col> = per-column). The server's
2738
- // planFromSearchParams reads these, so load() re-filters; reset to the first page.
2739
- function applyFilters(f: { global: string; columns: Array<{ id: string; value: string }> }) {
2740
- const sp = new URLSearchParams(page.url.searchParams)
2741
- for (const k of [...sp.keys()]) if (k === 'q' || k.startsWith('f_')) sp.delete(k)
2742
- if (f.global) sp.set('q', f.global)
2743
- for (const c of f.columns) if (c.value) sp.set('f_' + c.id, c.value)
2744
- sp.delete('page')
2745
- const q = sp.toString()
2746
- void goto(q ? \`?\${q}\` : page.url.pathname, { keepFocus: true, noScroll: true })
2747
- }
2748
- ` : ''}
2749
- // Progressive enhancement: post to the action, keep typed values on error,
2750
- // close the editor on success (load re-runs automatically, refreshing the grid).
2751
- const onSubmit: SubmitFunction = () => async ({ result, update }) => {
2752
- await update({ reset: false })
2753
- if (result.type === 'success') editing = null
2754
- }
2755
- </script>
2756
-
2757
- {#snippet rowActions({ row }: { row: ${n.type} })}
2758
- <div class="sk-rowact">
2759
- <button type="button" class="sk-link" onclick={() => (editing = row)}>Edit</button>
2760
- <form method="POST" action="?/delete" use:enhance={onSubmit} style="display:contents">
2761
- <input type="hidden" name="__id" value={(row as Record<string, unknown>)[ID_FIELD] as string} />
2762
- <button type="submit" class="sk-link sk-danger">Delete</button>
2763
- </form>
2764
- </div>
2765
- {/snippet}
2766
-
2767
- <header class="sk-head">
2768
- <h1>{TITLE}</h1>
2769
- <button type="button" class="sk-btn sk-btn--primary" onclick={() => (editing = 'create')}>{NEW_LABEL}</button>
2770
- </header>
2771
- ${facetForm}
2772
- <SvGrid
2773
- data={data.rows}
2774
- {columns}
2775
- externalSort
2776
- initialSorting={data.sort}
2777
- externalPagination${wantsFilter ? '\n filterable\n externalFilter\n onFiltersChange={applyFilters}' : ''}
2778
- rowCount={data.total}
2779
- pageIndex={data.page}
2780
- pageSize={data.size}
2781
- onSortingChange={(s) => setParams({ sort: s.map((x) => \`\${x.id}:\${x.desc ? 'desc' : 'asc'}\`).join(',') || null, page: null })}
2782
- onPaginationChange={(p) => setParams({ page: String(p.pageIndex), size: String(p.pageSize) })}
2783
- />
2784
-
2785
- {#if editing}
2786
- <div class="sk-overlay">
2787
- <form method="POST" action={isCreate ? '?/create' : '?/update'} class="sk-form" use:enhance={onSubmit}>
2788
- <h2>{isCreate ? NEW_LABEL : 'Edit ${htmlEsc(n.label)}'}</h2>
2789
- {#if !isCreate}<input type="hidden" name="__id" value={${row}[ID_FIELD] as string} />{/if}
2790
- ${fieldsMarkup}
2791
- <div class="sk-form__actions">
2792
- <button type="button" class="sk-btn" onclick={() => (editing = null)}>Cancel</button>
2793
- <button type="submit" class="sk-btn sk-btn--primary">Save</button>
2794
- </div>
2795
- </form>
2796
- </div>
2797
- {/if}
2798
-
2799
- <style>
2800
- .sk-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
2801
- .sk-head h1 { margin: 0; font-size: 20px; }
2802
- .sk-btn { font: inherit; padding: 7px 14px; border-radius: 8px; border: 1px solid var(--sg-border, #cbd5e1); background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); cursor: pointer; }
2803
- .sk-btn--primary { background: var(--sg-accent, #4f46e5); border-color: var(--sg-accent, #4f46e5); color: #fff; }
2804
- ${facetCss} .sk-rowact { display: flex; gap: 10px; }
2805
- .sk-link { background: none; border: none; padding: 0; font: inherit; color: var(--sg-accent, #4f46e5); cursor: pointer; }
2806
- .sk-danger { color: #dc2626; }
2807
- .sk-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.4); display: grid; place-items: center; z-index: 50; }
2808
- .sk-form { width: min(480px, 92vw); max-height: 90vh; overflow: auto; background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); border-radius: 12px; padding: 22px; display: flex; flex-direction: column; gap: 12px; box-shadow: 0 24px 60px -20px rgba(15, 23, 42, 0.5); }
2809
- .sk-form h2 { margin: 0 0 4px; font-size: 16px; }
2810
- .sk-group { display: grid; grid-template-columns: repeat(var(--sk-cols, 1), minmax(0, 1fr)); gap: 12px; border: 0; padding: 0; margin: 0; min-width: 0; }
2811
- .sk-group + .sk-group { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--sg-border, #e2e8f0); }
2812
- .sk-group legend { grid-column: 1 / -1; padding: 0; font-size: 12px; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase; color: var(--sg-muted, #64748b); }
2813
- .sk-group__desc { grid-column: 1 / -1; margin: 0; font-size: 12px; color: var(--sg-muted, #64748b); }
2814
- .sk-field--wide { grid-column: 1 / -1; }
2815
- .sk-hint { color: var(--sg-muted, #64748b); font-size: 11.5px; }
2816
- @media (max-width: 560px) { .sk-group { grid-template-columns: 1fr; } }
2817
- .sk-field { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
2818
- .sk-field span { color: var(--sg-muted, #64748b); }
2819
- .sk-field input, .sk-field select { font: inherit; padding: 7px 9px; border-radius: 8px; border: 1px solid var(--sg-border, #cbd5e1); background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); }
2820
- .sk-field input[type='checkbox'] { align-self: flex-start; width: auto; }
2821
- .sk-err { color: #dc2626; font-size: 12px; font-style: normal; }
2822
- .sk-form__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 6px; }
2823
- </style>
2770
+ const page = `<script lang="ts">
2771
+ import { SvGrid, renderSnippet, type ColumnDef, type CellContext } from '@svgrid/grid'
2772
+ import { schemaToColumns } from '@svgrid/enterprise'
2773
+ import { goto } from '$app/navigation'
2774
+ import { page } from '$app/state'
2775
+ import { enhance } from '$app/forms'
2776
+ import type { SubmitFunction } from '@sveltejs/kit'
2777
+ import { ${n.schemaVar}, type ${n.type} } from '$lib/schemas'
2778
+ import type { PageProps } from './$types'
2779
+
2780
+ let { data, form }: PageProps = $props()
2781
+
2782
+ const ID_FIELD = ${jsStr(idField)}
2783
+ const TITLE = ${jsStr(screen.title)}
2784
+ const NEW_LABEL = ${jsStr('New ' + n.label)}
2785
+
2786
+ // Grid columns from the schema + a row-actions column (Edit / Delete).
2787
+ const columns: ColumnDef<Record<string, never>, ${n.type}>[] = [
2788
+ ...(schemaToColumns(${n.schemaVar}) as ColumnDef<Record<string, never>, ${n.type}>[]),
2789
+ { id: '__actions', header: '', sortable: false, cell: (ctx: CellContext<${n.type}>) => renderSnippet(rowActions, { row: ctx.row.original }) },
2790
+ ]
2791
+
2792
+ // null = no editor; 'create' = new row; a row = editing that row.
2793
+ let editing = $state<'create' | ${n.type} | null>(null)
2794
+ const isCreate = $derived(editing === 'create')
2795
+
2796
+ // Sort / paginate by writing to the URL - load() re-runs on the server.
2797
+ function setParams(patch: Record<string, string | null>) {
2798
+ const sp = new URLSearchParams(page.url.searchParams)
2799
+ for (const [k, v] of Object.entries(patch)) { if (v == null) sp.delete(k); else sp.set(k, v) }
2800
+ const q = sp.toString()
2801
+ void goto(q ? \`?\${q}\` : page.url.pathname, { keepFocus: true, noScroll: true })
2802
+ }
2803
+ ${wantsFilter ? `
2804
+ // Filter via the URL (q = global search, f_<col> = per-column). The server's
2805
+ // planFromSearchParams reads these, so load() re-filters; reset to the first page.
2806
+ function applyFilters(f: { global: string; columns: Array<{ id: string; value: string }> }) {
2807
+ const sp = new URLSearchParams(page.url.searchParams)
2808
+ for (const k of [...sp.keys()]) if (k === 'q' || k.startsWith('f_')) sp.delete(k)
2809
+ if (f.global) sp.set('q', f.global)
2810
+ for (const c of f.columns) if (c.value) sp.set('f_' + c.id, c.value)
2811
+ sp.delete('page')
2812
+ const q = sp.toString()
2813
+ void goto(q ? \`?\${q}\` : page.url.pathname, { keepFocus: true, noScroll: true })
2814
+ }
2815
+ ` : ''}
2816
+ // Progressive enhancement: post to the action, keep typed values on error,
2817
+ // close the editor on success (load re-runs automatically, refreshing the grid).
2818
+ const onSubmit: SubmitFunction = () => async ({ result, update }) => {
2819
+ await update({ reset: false })
2820
+ if (result.type === 'success') editing = null
2821
+ }
2822
+ </script>
2823
+
2824
+ {#snippet rowActions({ row }: { row: ${n.type} })}
2825
+ <div class="sk-rowact">
2826
+ <button type="button" class="sk-link" onclick={() => (editing = row)}>Edit</button>
2827
+ <form method="POST" action="?/delete" use:enhance={onSubmit} style="display:contents">
2828
+ <input type="hidden" name="__id" value={(row as Record<string, unknown>)[ID_FIELD] as string} />
2829
+ <button type="submit" class="sk-link sk-danger">Delete</button>
2830
+ </form>
2831
+ </div>
2832
+ {/snippet}
2833
+
2834
+ <header class="sk-head">
2835
+ <h1>{TITLE}</h1>
2836
+ <button type="button" class="sk-btn sk-btn--primary" onclick={() => (editing = 'create')}>{NEW_LABEL}</button>
2837
+ </header>
2838
+ ${facetForm}
2839
+ <SvGrid
2840
+ data={data.rows}
2841
+ {columns}
2842
+ externalSort
2843
+ initialSorting={data.sort}
2844
+ externalPagination${wantsFilter ? '\n filterable\n externalFilter\n onFiltersChange={applyFilters}' : ''}
2845
+ rowCount={data.total}
2846
+ pageIndex={data.page}
2847
+ pageSize={data.size}
2848
+ onSortingChange={(s) => setParams({ sort: s.map((x) => \`\${x.id}:\${x.desc ? 'desc' : 'asc'}\`).join(',') || null, page: null })}
2849
+ onPaginationChange={(p) => setParams({ page: String(p.pageIndex), size: String(p.pageSize) })}
2850
+ />
2851
+
2852
+ {#if editing}
2853
+ <div class="sk-overlay">
2854
+ <form method="POST" action={isCreate ? '?/create' : '?/update'} class="sk-form" use:enhance={onSubmit}>
2855
+ <h2>{isCreate ? NEW_LABEL : 'Edit ${htmlEsc(n.label)}'}</h2>
2856
+ {#if !isCreate}<input type="hidden" name="__id" value={${row}[ID_FIELD] as string} />{/if}
2857
+ ${fieldsMarkup}
2858
+ <div class="sk-form__actions">
2859
+ <button type="button" class="sk-btn" onclick={() => (editing = null)}>Cancel</button>
2860
+ <button type="submit" class="sk-btn sk-btn--primary">Save</button>
2861
+ </div>
2862
+ </form>
2863
+ </div>
2864
+ {/if}
2865
+
2866
+ <style>
2867
+ .sk-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
2868
+ .sk-head h1 { margin: 0; font-size: 20px; }
2869
+ .sk-btn { font: inherit; padding: 7px 14px; border-radius: 8px; border: 1px solid var(--sg-border, #cbd5e1); background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); cursor: pointer; }
2870
+ .sk-btn--primary { background: var(--sg-accent, #4f46e5); border-color: var(--sg-accent, #4f46e5); color: #fff; }
2871
+ ${facetCss} .sk-rowact { display: flex; gap: 10px; }
2872
+ .sk-link { background: none; border: none; padding: 0; font: inherit; color: var(--sg-accent, #4f46e5); cursor: pointer; }
2873
+ .sk-danger { color: #dc2626; }
2874
+ .sk-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.4); display: grid; place-items: center; z-index: 50; }
2875
+ .sk-form { width: min(480px, 92vw); max-height: 90vh; overflow: auto; background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); border-radius: 12px; padding: 22px; display: flex; flex-direction: column; gap: 12px; box-shadow: 0 24px 60px -20px rgba(15, 23, 42, 0.5); }
2876
+ .sk-form h2 { margin: 0 0 4px; font-size: 16px; }
2877
+ .sk-group { display: grid; grid-template-columns: repeat(var(--sk-cols, 1), minmax(0, 1fr)); gap: 12px; border: 0; padding: 0; margin: 0; min-width: 0; }
2878
+ .sk-group + .sk-group { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--sg-border, #e2e8f0); }
2879
+ .sk-group legend { grid-column: 1 / -1; padding: 0; font-size: 12px; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase; color: var(--sg-muted, #64748b); }
2880
+ .sk-group__desc { grid-column: 1 / -1; margin: 0; font-size: 12px; color: var(--sg-muted, #64748b); }
2881
+ /* A foldable group is a native <details>, so it works with JavaScript off.
2882
+ display:grid on the element itself would lay the <summary> out as a grid
2883
+ item and break the disclosure, so the grid moves to [open] children. */
2884
+ .sk-group--fold { display: block; }
2885
+ .sk-group--fold > summary { grid-column: 1 / -1; margin-bottom: 12px; font-size: 13.5px; font-weight: 650; color: var(--sg-fg, #0f172a); cursor: pointer; list-style-position: inside; }
2886
+ .sk-group--fold[open] { display: grid; }
2887
+ .sk-group--fold[open] > summary { margin-bottom: 0; }
2888
+ .sk-field--wide { grid-column: 1 / -1; }
2889
+ .sk-hint { color: var(--sg-muted, #64748b); font-size: 11.5px; }
2890
+ @media (max-width: 560px) { .sk-group { grid-template-columns: 1fr; } }
2891
+ .sk-field { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
2892
+ .sk-field span { color: var(--sg-muted, #64748b); }
2893
+ .sk-field input, .sk-field select { font: inherit; padding: 7px 9px; border-radius: 8px; border: 1px solid var(--sg-border, #cbd5e1); background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); }
2894
+ .sk-field input[type='checkbox'] { align-self: flex-start; width: auto; }
2895
+ .sk-err { color: #dc2626; font-size: 12px; font-style: normal; }
2896
+ .sk-form__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 6px; }
2897
+ </style>
2824
2898
  `;
2825
2899
  return [
2826
2900
  { path: `src/routes/${screen.route}/+page.server.ts`, description: `SSR load + CRUD form actions for ${n.label}.`, contents: server },
@@ -3015,29 +3089,29 @@ function i18nModule(project) {
3015
3089
  return {
3016
3090
  path: 'src/lib/i18n.ts',
3017
3091
  description: 'Localization: locales, the current-locale store, the message catalog, and t() / localizeCols helpers.',
3018
- contents: `import { writable, derived } from 'svelte/store'
3019
-
3020
- export type Locale = ${localeUnion}
3021
- export const locales: Locale[] = ${JSON.stringify(locales)} as Locale[]
3022
- export const currentLocale = writable<Locale>(${JSON.stringify(def)})
3023
-
3024
- // Seeded from your schema + screen labels. Every locale starts with the same
3025
- // keys and the default-locale text, so translating is editing values in place -
3026
- // no key hunting. Anything you delete falls back to the default locale.
3027
- const messages: Record<Locale, Record<string, string>> = {
3028
- ${messagesEntries}
3029
- }
3030
-
3031
- /** Reactive translator: \`$t('key', 'fallback')\`. Falls back to the default locale, then the fallback, then the key. */
3032
- export const t = derived(currentLocale, ($l) => (key: string, fallback?: string): string =>
3033
- messages[$l]?.[key] ?? messages[${JSON.stringify(def)}]?.[key] ?? fallback ?? key)
3034
-
3035
- /** Localize a column list's headers via \`col.<entity>.<field>\` keys. */
3036
- export function localizeCols<T extends { field?: string | number; header?: string }>(
3037
- cols: T[], entity: string, translate: (k: string, fb?: string) => string,
3038
- ): T[] {
3039
- return cols.map((c) => ({ ...c, header: translate('col.' + entity + '.' + String(c.field), c.header ?? String(c.field ?? '')) }))
3040
- }
3092
+ contents: `import { writable, derived } from 'svelte/store'
3093
+
3094
+ export type Locale = ${localeUnion}
3095
+ export const locales: Locale[] = ${JSON.stringify(locales)} as Locale[]
3096
+ export const currentLocale = writable<Locale>(${JSON.stringify(def)})
3097
+
3098
+ // Seeded from your schema + screen labels. Every locale starts with the same
3099
+ // keys and the default-locale text, so translating is editing values in place -
3100
+ // no key hunting. Anything you delete falls back to the default locale.
3101
+ const messages: Record<Locale, Record<string, string>> = {
3102
+ ${messagesEntries}
3103
+ }
3104
+
3105
+ /** Reactive translator: \`$t('key', 'fallback')\`. Falls back to the default locale, then the fallback, then the key. */
3106
+ export const t = derived(currentLocale, ($l) => (key: string, fallback?: string): string =>
3107
+ messages[$l]?.[key] ?? messages[${JSON.stringify(def)}]?.[key] ?? fallback ?? key)
3108
+
3109
+ /** Localize a column list's headers via \`col.<entity>.<field>\` keys. */
3110
+ export function localizeCols<T extends { field?: string | number; header?: string }>(
3111
+ cols: T[], entity: string, translate: (k: string, fb?: string) => string,
3112
+ ): T[] {
3113
+ return cols.map((c) => ({ ...c, header: translate('col.' + entity + '.' + String(c.field), c.header ?? String(c.field ?? '')) }))
3114
+ }
3041
3115
  `,
3042
3116
  };
3043
3117
  }
@@ -3054,60 +3128,60 @@ function auditModule(persisted = false) {
3054
3128
  return {
3055
3129
  path: 'src/lib/audit.ts',
3056
3130
  description: 'Audit trail store: the AuditEntry schema, an in-memory source, and recordAudit(). NOT persisted - enable the typed data layer to write to a real table.',
3057
- contents: `import { createInMemoryDataSource } from '@svgrid/enterprise'
3058
- import type { EntitySchema } from '@svgrid/enterprise'
3059
-
3060
- export type AuditEntry = {
3061
- id: string
3062
- at: string
3063
- actor: string
3064
- entity: string
3065
- action: 'create' | 'update' | 'delete'
3066
- recordId: string
3067
- summary: string
3068
- }
3069
-
3070
- export const auditSchema: EntitySchema<AuditEntry> = {
3071
- name: 'audit',
3072
- idField: 'id',
3073
- fields: [
3074
- { field: 'id', type: 'text', primaryKey: true, readonly: true },
3075
- { field: 'at', type: 'datetime', label: 'When' },
3076
- { field: 'actor', type: 'text', label: 'Actor' },
3077
- { field: 'entity', type: 'text', label: 'Entity' },
3078
- { field: 'action', type: 'enum', label: 'Action', options: [{ value: 'create', label: 'Create' }, { value: 'update', label: 'Update' }, { value: 'delete', label: 'Delete' }] },
3079
- { field: 'recordId', type: 'text', label: 'Record' },
3080
- { field: 'summary', type: 'text', label: 'Summary' },
3081
- ],
3082
- }
3083
-
3084
- // In-memory, server-side singleton. Replace with a SQL / Supabase source to
3085
- // persist the trail across restarts (recordAudit + the /audit viewer keep working).
3086
- export const auditSource = createInMemoryDataSource<AuditEntry>([], auditSchema)
3087
- let seq = 0
3088
-
3089
- /** Append one change record. Called by the API routes' \`audit\` hook. */
3090
- export async function recordAudit(input: {
3091
- entity: string
3092
- action: 'create' | 'update' | 'delete'
3093
- recordId: string | null
3094
- values?: Record<string, unknown>
3095
- actor?: string
3096
- }): Promise<void> {
3097
- const summary =
3098
- input.action === 'delete'
3099
- ? \`Deleted \${input.entity} \${input.recordId ?? ''}\`.trim()
3100
- : \`\${input.action === 'create' ? 'Created' : 'Updated'} \${input.entity}\${input.values ? ' (' + Object.keys(input.values).join(', ') + ')' : ''}\`
3101
- await auditSource.createRow?.({
3102
- id: String(++seq),
3103
- at: new Date().toISOString(),
3104
- actor: input.actor ?? 'system',
3105
- entity: input.entity,
3106
- action: input.action,
3107
- recordId: input.recordId ?? '',
3108
- summary,
3109
- })
3110
- }
3131
+ contents: `import { createInMemoryDataSource } from '@svgrid/enterprise'
3132
+ import type { EntitySchema } from '@svgrid/enterprise'
3133
+
3134
+ export type AuditEntry = {
3135
+ id: string
3136
+ at: string
3137
+ actor: string
3138
+ entity: string
3139
+ action: 'create' | 'update' | 'delete'
3140
+ recordId: string
3141
+ summary: string
3142
+ }
3143
+
3144
+ export const auditSchema: EntitySchema<AuditEntry> = {
3145
+ name: 'audit',
3146
+ idField: 'id',
3147
+ fields: [
3148
+ { field: 'id', type: 'text', primaryKey: true, readonly: true },
3149
+ { field: 'at', type: 'datetime', label: 'When' },
3150
+ { field: 'actor', type: 'text', label: 'Actor' },
3151
+ { field: 'entity', type: 'text', label: 'Entity' },
3152
+ { field: 'action', type: 'enum', label: 'Action', options: [{ value: 'create', label: 'Create' }, { value: 'update', label: 'Update' }, { value: 'delete', label: 'Delete' }] },
3153
+ { field: 'recordId', type: 'text', label: 'Record' },
3154
+ { field: 'summary', type: 'text', label: 'Summary' },
3155
+ ],
3156
+ }
3157
+
3158
+ // In-memory, server-side singleton. Replace with a SQL / Supabase source to
3159
+ // persist the trail across restarts (recordAudit + the /audit viewer keep working).
3160
+ export const auditSource = createInMemoryDataSource<AuditEntry>([], auditSchema)
3161
+ let seq = 0
3162
+
3163
+ /** Append one change record. Called by the API routes' \`audit\` hook. */
3164
+ export async function recordAudit(input: {
3165
+ entity: string
3166
+ action: 'create' | 'update' | 'delete'
3167
+ recordId: string | null
3168
+ values?: Record<string, unknown>
3169
+ actor?: string
3170
+ }): Promise<void> {
3171
+ const summary =
3172
+ input.action === 'delete'
3173
+ ? \`Deleted \${input.entity} \${input.recordId ?? ''}\`.trim()
3174
+ : \`\${input.action === 'create' ? 'Created' : 'Updated'} \${input.entity}\${input.values ? ' (' + Object.keys(input.values).join(', ') + ')' : ''}\`
3175
+ await auditSource.createRow?.({
3176
+ id: String(++seq),
3177
+ at: new Date().toISOString(),
3178
+ actor: input.actor ?? 'system',
3179
+ entity: input.entity,
3180
+ action: input.action,
3181
+ recordId: input.recordId ?? '',
3182
+ summary,
3183
+ })
3184
+ }
3111
3185
  `,
3112
3186
  };
3113
3187
  }
@@ -3118,92 +3192,92 @@ function auditModulePersisted() {
3118
3192
  return {
3119
3193
  path: 'src/lib/audit.ts',
3120
3194
  description: 'Audit trail: the AuditEntry schema, a Drizzle-backed source over audit_log, and recordAudit() with before/after snapshots.',
3121
- contents: `import { count, desc } from 'drizzle-orm'
3122
- import type { EntitySchema } from '@svgrid/enterprise'
3123
- import { db } from '../lib/server/db'
3124
- import { auditLog } from '../lib/server/db/schema'
3125
-
3126
- export type AuditEntry = {
3127
- id: string
3128
- at: string
3129
- actor: string
3130
- entity: string
3131
- action: 'create' | 'update' | 'delete'
3132
- recordId: string
3133
- summary: string
3134
- /** JSON snapshot of the row before the change (update / delete). */
3135
- before?: string | null
3136
- /** JSON snapshot of the row after it (create / update). */
3137
- after?: string | null
3138
- }
3139
-
3140
- export const auditSchema: EntitySchema<AuditEntry> = {
3141
- name: 'audit',
3142
- idField: 'id',
3143
- fields: [
3144
- { field: 'id', type: 'text', primaryKey: true, readonly: true },
3145
- { field: 'at', type: 'datetime', label: 'When' },
3146
- { field: 'actor', type: 'text', label: 'Actor' },
3147
- { field: 'entity', type: 'text', label: 'Entity' },
3148
- { field: 'action', type: 'enum', label: 'Action', options: [{ value: 'create', label: 'Create' }, { value: 'update', label: 'Update' }, { value: 'delete', label: 'Delete' }] },
3149
- { field: 'recordId', type: 'text', label: 'Record' },
3150
- { field: 'summary', type: 'text', label: 'Summary' },
3151
- { field: 'before', type: 'json', label: 'Before', readonly: true },
3152
- { field: 'after', type: 'json', label: 'After', readonly: true },
3153
- ],
3154
- }
3155
-
3156
- const toEntry = (r: typeof auditLog.$inferSelect): AuditEntry => ({
3157
- id: String(r.id),
3158
- at: typeof r.at === 'string' ? r.at : new Date(r.at as unknown as Date).toISOString(),
3159
- actor: r.actor,
3160
- entity: r.entity,
3161
- action: r.action as AuditEntry['action'],
3162
- recordId: r.recordId,
3163
- summary: r.summary,
3164
- before: r.before ?? null,
3165
- after: r.after ?? null,
3166
- })
3167
-
3168
- /** Read-only \\\`ServerDataSource\\\` for the /audit viewer. Newest first, paged in
3169
- * the database so a long trail doesn't load in one go. No write methods: the
3170
- * trail is append-only through \\\`recordAudit\\\`. */
3171
- export const auditSource = {
3172
- async getRows(request: { startRow?: number; endRow?: number }) {
3173
- const start = request.startRow ?? 0
3174
- const limit = Math.max(1, (request.endRow ?? start + 25) - start)
3175
- const [rows, counted] = await Promise.all([
3176
- db.select().from(auditLog).orderBy(desc(auditLog.at)).limit(limit).offset(start),
3177
- db.select({ n: count() }).from(auditLog),
3178
- ])
3179
- return { rows: rows.map(toEntry), rowCount: Number(counted.at(0)?.n ?? 0) }
3180
- },
3181
- }
3182
-
3183
- /** Append one change record. Called by the API routes' \\\`audit\\\` hook. */
3184
- export async function recordAudit(input: {
3185
- entity: string
3186
- action: 'create' | 'update' | 'delete'
3187
- recordId: string | null
3188
- values?: Record<string, unknown>
3189
- before?: Record<string, unknown> | null
3190
- actor?: string
3191
- }): Promise<void> {
3192
- const summary =
3193
- input.action === 'delete'
3194
- ? \`Deleted \${input.entity} \${input.recordId ?? ''}\`.trim()
3195
- : \`\${input.action === 'create' ? 'Created' : 'Updated'} \${input.entity}\${input.values ? ' (' + Object.keys(input.values).join(', ') + ')' : ''}\`
3196
- await db.insert(auditLog).values({
3197
- at: new Date().toISOString(),
3198
- actor: input.actor ?? 'system',
3199
- entity: input.entity,
3200
- action: input.action,
3201
- recordId: input.recordId ?? '',
3202
- summary,
3203
- before: input.before ? JSON.stringify(input.before) : null,
3204
- after: input.values ? JSON.stringify(input.values) : null,
3205
- } as typeof auditLog.$inferInsert)
3206
- }
3195
+ contents: `import { count, desc } from 'drizzle-orm'
3196
+ import type { EntitySchema } from '@svgrid/enterprise'
3197
+ import { db } from '../lib/server/db'
3198
+ import { auditLog } from '../lib/server/db/schema'
3199
+
3200
+ export type AuditEntry = {
3201
+ id: string
3202
+ at: string
3203
+ actor: string
3204
+ entity: string
3205
+ action: 'create' | 'update' | 'delete'
3206
+ recordId: string
3207
+ summary: string
3208
+ /** JSON snapshot of the row before the change (update / delete). */
3209
+ before?: string | null
3210
+ /** JSON snapshot of the row after it (create / update). */
3211
+ after?: string | null
3212
+ }
3213
+
3214
+ export const auditSchema: EntitySchema<AuditEntry> = {
3215
+ name: 'audit',
3216
+ idField: 'id',
3217
+ fields: [
3218
+ { field: 'id', type: 'text', primaryKey: true, readonly: true },
3219
+ { field: 'at', type: 'datetime', label: 'When' },
3220
+ { field: 'actor', type: 'text', label: 'Actor' },
3221
+ { field: 'entity', type: 'text', label: 'Entity' },
3222
+ { field: 'action', type: 'enum', label: 'Action', options: [{ value: 'create', label: 'Create' }, { value: 'update', label: 'Update' }, { value: 'delete', label: 'Delete' }] },
3223
+ { field: 'recordId', type: 'text', label: 'Record' },
3224
+ { field: 'summary', type: 'text', label: 'Summary' },
3225
+ { field: 'before', type: 'json', label: 'Before', readonly: true },
3226
+ { field: 'after', type: 'json', label: 'After', readonly: true },
3227
+ ],
3228
+ }
3229
+
3230
+ const toEntry = (r: typeof auditLog.$inferSelect): AuditEntry => ({
3231
+ id: String(r.id),
3232
+ at: typeof r.at === 'string' ? r.at : new Date(r.at as unknown as Date).toISOString(),
3233
+ actor: r.actor,
3234
+ entity: r.entity,
3235
+ action: r.action as AuditEntry['action'],
3236
+ recordId: r.recordId,
3237
+ summary: r.summary,
3238
+ before: r.before ?? null,
3239
+ after: r.after ?? null,
3240
+ })
3241
+
3242
+ /** Read-only \\\`ServerDataSource\\\` for the /audit viewer. Newest first, paged in
3243
+ * the database so a long trail doesn't load in one go. No write methods: the
3244
+ * trail is append-only through \\\`recordAudit\\\`. */
3245
+ export const auditSource = {
3246
+ async getRows(request: { startRow?: number; endRow?: number }) {
3247
+ const start = request.startRow ?? 0
3248
+ const limit = Math.max(1, (request.endRow ?? start + 25) - start)
3249
+ const [rows, counted] = await Promise.all([
3250
+ db.select().from(auditLog).orderBy(desc(auditLog.at)).limit(limit).offset(start),
3251
+ db.select({ n: count() }).from(auditLog),
3252
+ ])
3253
+ return { rows: rows.map(toEntry), rowCount: Number(counted.at(0)?.n ?? 0) }
3254
+ },
3255
+ }
3256
+
3257
+ /** Append one change record. Called by the API routes' \\\`audit\\\` hook. */
3258
+ export async function recordAudit(input: {
3259
+ entity: string
3260
+ action: 'create' | 'update' | 'delete'
3261
+ recordId: string | null
3262
+ values?: Record<string, unknown>
3263
+ before?: Record<string, unknown> | null
3264
+ actor?: string
3265
+ }): Promise<void> {
3266
+ const summary =
3267
+ input.action === 'delete'
3268
+ ? \`Deleted \${input.entity} \${input.recordId ?? ''}\`.trim()
3269
+ : \`\${input.action === 'create' ? 'Created' : 'Updated'} \${input.entity}\${input.values ? ' (' + Object.keys(input.values).join(', ') + ')' : ''}\`
3270
+ await db.insert(auditLog).values({
3271
+ at: new Date().toISOString(),
3272
+ actor: input.actor ?? 'system',
3273
+ entity: input.entity,
3274
+ action: input.action,
3275
+ recordId: input.recordId ?? '',
3276
+ summary,
3277
+ before: input.before ? JSON.stringify(input.before) : null,
3278
+ after: input.values ? JSON.stringify(input.values) : null,
3279
+ } as typeof auditLog.$inferInsert)
3280
+ }
3207
3281
  `,
3208
3282
  };
3209
3283
  }
@@ -3218,31 +3292,31 @@ function tenantModule(field) {
3218
3292
  return {
3219
3293
  path: 'src/lib/server/tenant.ts',
3220
3294
  description: 'Resolve the caller\'s tenant from the session; used to scope every API route.',
3221
- contents: `// Regenerated by SvGrid Studio. Multi-tenancy: which tenant is calling?
3222
- //
3223
- // The tenant is carried on the session (see hooks.server.ts / auth.ts) and
3224
- // stamped onto \`event.locals\`. Every scoped API route calls requireTenant().
3225
-
3226
- export type TenantEvent = { locals?: Record<string, unknown> }
3227
-
3228
- /** The caller's tenant id, or null when there is none. */
3229
- export function getTenant(event: TenantEvent): string | null {
3230
- const t = event.locals?.${field}
3231
- return t == null || t === '' ? null : String(t)
3232
- }
3233
-
3234
- /**
3235
- * The caller's tenant id, or THROW.
3236
- *
3237
- * Throwing is the point: the route's \`scope\` turns it into a 403. Returning
3238
- * null here would let the query run unscoped, which is the one failure mode
3239
- * multi-tenancy cannot have.
3240
- */
3241
- export function requireTenant(event: TenantEvent): string {
3242
- const t = getTenant(event)
3243
- if (!t) throw new Error('No tenant on the session - sign in again.')
3244
- return t
3245
- }
3295
+ contents: `// Regenerated by SvGrid Studio. Multi-tenancy: which tenant is calling?
3296
+ //
3297
+ // The tenant is carried on the session (see hooks.server.ts / auth.ts) and
3298
+ // stamped onto \`event.locals\`. Every scoped API route calls requireTenant().
3299
+
3300
+ export type TenantEvent = { locals?: Record<string, unknown> }
3301
+
3302
+ /** The caller's tenant id, or null when there is none. */
3303
+ export function getTenant(event: TenantEvent): string | null {
3304
+ const t = event.locals?.${field}
3305
+ return t == null || t === '' ? null : String(t)
3306
+ }
3307
+
3308
+ /**
3309
+ * The caller's tenant id, or THROW.
3310
+ *
3311
+ * Throwing is the point: the route's \`scope\` turns it into a 403. Returning
3312
+ * null here would let the query run unscoped, which is the one failure mode
3313
+ * multi-tenancy cannot have.
3314
+ */
3315
+ export function requireTenant(event: TenantEvent): string {
3316
+ const t = getTenant(event)
3317
+ if (!t) throw new Error('No tenant on the session - sign in again.')
3318
+ return t
3319
+ }
3246
3320
  `,
3247
3321
  };
3248
3322
  }
@@ -3267,10 +3341,10 @@ function jobsFiles(project, jobs, emailAvailable) {
3267
3341
  return ` ${JSON.stringify(j.id)}: async () => {\n // ${label}: cannot send - ${why}.\n console.warn('cron ${j.id}: skipped (${why})')\n },`;
3268
3342
  }
3269
3343
  const n = namesFor(ent);
3270
- return ` ${JSON.stringify(j.id)}: async () => {
3271
- const { rows, rowCount } = await ${n.sourceVar}.getRows({ startRow: 0, endRow: 10, sortModel: [], filterModel: {} })
3272
- const items = rows.map((r) => '<li>' + Object.values(r).slice(0, 3).map(String).join(' &middot; ') + '</li>').join('')
3273
- await sendEmail(${to}, ${subject}, '<p>' + rowCount + ' ${ent.name} total. Most recent:</p><ul>' + items + '</ul>')
3344
+ return ` ${JSON.stringify(j.id)}: async () => {
3345
+ const { rows, rowCount } = await ${n.sourceVar}.getRows({ startRow: 0, endRow: 10, sortModel: [], filterModel: {} })
3346
+ const items = rows.map((r) => '<li>' + Object.values(r).slice(0, 3).map(String).join(' &middot; ') + '</li>').join('')
3347
+ await sendEmail(${to}, ${subject}, '<p>' + rowCount + ' ${ent.name} total. Most recent:</p><ul>' + items + '</ul>')
3274
3348
  },`;
3275
3349
  }
3276
3350
  const body = (j.code ?? '').trim() || `console.log('cron ${j.id}: no body yet')`;
@@ -3289,59 +3363,59 @@ function jobsFiles(project, jobs, emailAvailable) {
3289
3363
  ...(jobs.some((j) => j.kind === 'email') && emailAvailable ? [`import { sendEmail } from '../lib/server/email'`] : []),
3290
3364
  ];
3291
3365
  const table = jobs.map((j) => ` * ${j.id.padEnd(20)} ${j.cron.padEnd(16)} ${j.name}`).join('\n');
3292
- const jobsTs = `// Regenerated by SvGrid Studio. Scheduled job handlers.
3293
- //
3294
- // Schedule (UTC):
3295
- ${table}
3296
- //
3297
- // Runs on the server, triggered by /api/cron - NOT in the browser. Edit a
3298
- // handler body freely; the registry keys are what /api/cron dispatches on.
3299
- ${imports.join('\n')}
3300
-
3301
- export const jobs: Record<string, () => Promise<void>> = {
3302
- ${jobs.map(handler).join('\n')}
3303
- }
3304
-
3305
- /** Job ids that are switched on. /api/cron without ?job= runs exactly these. */
3306
- export const enabledJobs: string[] = ${JSON.stringify(jobs.filter((j) => j.enabled !== false).map((j) => j.id))}
3366
+ const jobsTs = `// Regenerated by SvGrid Studio. Scheduled job handlers.
3367
+ //
3368
+ // Schedule (UTC):
3369
+ ${table}
3370
+ //
3371
+ // Runs on the server, triggered by /api/cron - NOT in the browser. Edit a
3372
+ // handler body freely; the registry keys are what /api/cron dispatches on.
3373
+ ${imports.join('\n')}
3374
+
3375
+ export const jobs: Record<string, () => Promise<void>> = {
3376
+ ${jobs.map(handler).join('\n')}
3377
+ }
3378
+
3379
+ /** Job ids that are switched on. /api/cron without ?job= runs exactly these. */
3380
+ export const enabledJobs: string[] = ${JSON.stringify(jobs.filter((j) => j.enabled !== false).map((j) => j.id))}
3307
3381
  `;
3308
- const routeTs = `import { json, error } from '@sveltejs/kit'
3309
- import { env } from '$env/dynamic/private'
3310
- import type { RequestHandler } from './$types'
3311
- import { jobs, enabledJobs } from '../lib/server/jobs'
3312
-
3313
- /**
3314
- * Scheduled-job endpoint. Your platform's scheduler calls this (Vercel Cron, a
3315
- * GitHub Actions schedule, or a crontab running \`curl\`). See DEPLOY.md.
3316
- *
3317
- * Guarded by CRON_SECRET: send it as \`Authorization: Bearer <secret>\` or
3318
- * \`?secret=<secret>\`. With no CRON_SECRET set the route refuses to run rather
3319
- * than leaving a public "do work" URL open.
3320
- */
3321
- const authorized = (request: Request, url: URL): boolean => {
3322
- const secret = env.CRON_SECRET
3323
- if (!secret) return false
3324
- const header = request.headers.get('authorization')
3325
- return header === 'Bearer ' + secret || url.searchParams.get('secret') === secret
3326
- }
3327
-
3328
- const run: RequestHandler = async ({ request, url }) => {
3329
- if (!authorized(request, url)) throw error(401, 'cron: missing or invalid CRON_SECRET')
3330
- const only = url.searchParams.get('job')
3331
- const ids = only ? [only] : enabledJobs
3332
- const results: Array<{ job: string; ok: boolean; error?: string }> = []
3333
- for (const id of ids) {
3334
- const fn = jobs[id]
3335
- if (!fn) { results.push({ job: id, ok: false, error: 'unknown job' }); continue }
3336
- // One failing job must not stop the rest of the run.
3337
- try { await fn(); results.push({ job: id, ok: true }) }
3338
- catch (err) { results.push({ job: id, ok: false, error: err instanceof Error ? err.message : String(err) }) }
3339
- }
3340
- return json({ ran: results.length, results })
3341
- }
3342
-
3343
- export const GET = run
3344
- export const POST = run
3382
+ const routeTs = `import { json, error } from '@sveltejs/kit'
3383
+ import { env } from '$env/dynamic/private'
3384
+ import type { RequestHandler } from './$types'
3385
+ import { jobs, enabledJobs } from '../lib/server/jobs'
3386
+
3387
+ /**
3388
+ * Scheduled-job endpoint. Your platform's scheduler calls this (Vercel Cron, a
3389
+ * GitHub Actions schedule, or a crontab running \`curl\`). See DEPLOY.md.
3390
+ *
3391
+ * Guarded by CRON_SECRET: send it as \`Authorization: Bearer <secret>\` or
3392
+ * \`?secret=<secret>\`. With no CRON_SECRET set the route refuses to run rather
3393
+ * than leaving a public "do work" URL open.
3394
+ */
3395
+ const authorized = (request: Request, url: URL): boolean => {
3396
+ const secret = env.CRON_SECRET
3397
+ if (!secret) return false
3398
+ const header = request.headers.get('authorization')
3399
+ return header === 'Bearer ' + secret || url.searchParams.get('secret') === secret
3400
+ }
3401
+
3402
+ const run: RequestHandler = async ({ request, url }) => {
3403
+ if (!authorized(request, url)) throw error(401, 'cron: missing or invalid CRON_SECRET')
3404
+ const only = url.searchParams.get('job')
3405
+ const ids = only ? [only] : enabledJobs
3406
+ const results: Array<{ job: string; ok: boolean; error?: string }> = []
3407
+ for (const id of ids) {
3408
+ const fn = jobs[id]
3409
+ if (!fn) { results.push({ job: id, ok: false, error: 'unknown job' }); continue }
3410
+ // One failing job must not stop the rest of the run.
3411
+ try { await fn(); results.push({ job: id, ok: true }) }
3412
+ catch (err) { results.push({ job: id, ok: false, error: err instanceof Error ? err.message : String(err) }) }
3413
+ }
3414
+ return json({ ran: results.length, results })
3415
+ }
3416
+
3417
+ export const GET = run
3418
+ export const POST = run
3345
3419
  `;
3346
3420
  return [
3347
3421
  { path: 'src/lib/server/jobs.ts', description: 'Scheduled job handlers, keyed by job id.', contents: jobsTs },
@@ -3353,10 +3427,10 @@ function auditRouteFile() {
3353
3427
  return {
3354
3428
  path: 'src/routes/api/audit/+server.ts',
3355
3429
  description: 'API route for the audit trail (read-only viewer feed).',
3356
- contents: `import { createKitHandlers } from '@svgrid/enterprise'
3357
- import { auditSchema, auditSource } from '../lib/audit'
3358
-
3359
- export const { POST } = createKitHandlers({ schema: auditSchema, source: auditSource })
3430
+ contents: `import { createKitHandlers } from '@svgrid/enterprise'
3431
+ import { auditSchema, auditSource } from '../lib/audit'
3432
+
3433
+ export const { POST } = createKitHandlers({ schema: auditSchema, source: auditSource })
3360
3434
  `,
3361
3435
  };
3362
3436
  }
@@ -3365,40 +3439,40 @@ function auditViewerPage() {
3365
3439
  return {
3366
3440
  path: 'src/routes/audit/+page.svelte',
3367
3441
  description: 'Audit log viewer (read-only grid of change records).',
3368
- contents: `<script lang="ts">
3369
- import { SvGrid, createServerDataSource, type ServerState } from '@svgrid/grid'
3370
- import { schemaToColumns, createKitDataSource } from '@svgrid/enterprise'
3371
- import { auditSchema, type AuditEntry } from '../lib/audit'
3372
-
3373
- const source = createKitDataSource<AuditEntry>({ endpoint: '/api/audit' })
3374
- let view = $state<ServerState<AuditEntry>>({ rows: [], total: 0, loading: false, saving: false, error: null, pageIndex: 0, pageSize: 25, pageCount: 1, sortModel: [], filterModel: {} })
3375
- const controller = createServerDataSource<AuditEntry>(source, { pageSize: 25, onChange: (s) => (view = s) })
3376
- $effect(() => { controller.refresh(); return () => controller.dispose() })
3377
- const columns = schemaToColumns(auditSchema)
3378
- </script>
3379
-
3380
- <h1 class="st__title">Audit log</h1>
3381
- <p class="st__sub">Every create, update, and delete recorded server-side.</p>
3382
-
3383
- <div class="screen" style="margin-top: 16px">
3384
- <SvGrid
3385
- data={view.rows}
3386
- columns={columns}
3387
- loading={view.loading}
3388
- loadingOverlay
3389
- fitColumns
3390
- sortable
3391
- externalSort
3392
- onSortingChange={(s) => controller.setSort(s)}
3393
- showPagination
3394
- externalPagination
3395
- rowCount={view.total}
3396
- pageIndex={view.pageIndex}
3397
- pageSize={view.pageSize}
3398
- onPaginationChange={({ pageIndex, pageSize }) => (pageSize !== view.pageSize ? controller.setPageSize(pageSize) : controller.setPage(pageIndex))}
3399
- containerHeight={520}
3400
- />
3401
- </div>
3442
+ contents: `<script lang="ts">
3443
+ import { SvGrid, createServerDataSource, type ServerState } from '@svgrid/grid'
3444
+ import { schemaToColumns, createKitDataSource } from '@svgrid/enterprise'
3445
+ import { auditSchema, type AuditEntry } from '../lib/audit'
3446
+
3447
+ const source = createKitDataSource<AuditEntry>({ endpoint: '/api/audit' })
3448
+ let view = $state<ServerState<AuditEntry>>({ rows: [], total: 0, loading: false, saving: false, error: null, pageIndex: 0, pageSize: 25, pageCount: 1, sortModel: [], filterModel: {} })
3449
+ const controller = createServerDataSource<AuditEntry>(source, { pageSize: 25, onChange: (s) => (view = s) })
3450
+ $effect(() => { controller.refresh(); return () => controller.dispose() })
3451
+ const columns = schemaToColumns(auditSchema)
3452
+ </script>
3453
+
3454
+ <h1 class="st__title">Audit log</h1>
3455
+ <p class="st__sub">Every create, update, and delete recorded server-side.</p>
3456
+
3457
+ <div class="screen" style="margin-top: 16px">
3458
+ <SvGrid
3459
+ data={view.rows}
3460
+ columns={columns}
3461
+ loading={view.loading}
3462
+ loadingOverlay
3463
+ fitColumns
3464
+ sortable
3465
+ externalSort
3466
+ onSortingChange={(s) => controller.setSort(s)}
3467
+ showPagination
3468
+ externalPagination
3469
+ rowCount={view.total}
3470
+ pageIndex={view.pageIndex}
3471
+ pageSize={view.pageSize}
3472
+ onPaginationChange={({ pageIndex, pageSize }) => (pageSize !== view.pageSize ? controller.setPageSize(pageSize) : controller.setPage(pageIndex))}
3473
+ containerHeight={520}
3474
+ />
3475
+ </div>
3402
3476
  `,
3403
3477
  };
3404
3478
  }
@@ -3414,51 +3488,51 @@ function accessModule(project) {
3414
3488
  return {
3415
3489
  path: 'src/lib/access.ts',
3416
3490
  description: 'RBAC policy: roles, screen + action permissions, the current-role store, and server helpers. Shared by the UI and the API routes.',
3417
- contents: `import { writable } from 'svelte/store'
3418
-
3419
- export type AppRole = ${roleUnion}
3420
- export type WriteAction = 'create' | 'update' | 'delete'
3421
- export const ROLES: AppRole[] = ${JSON.stringify(roleNames)} as AppRole[]
3422
-
3423
- const SCREENS: Record<AppRole, '*' | string[]> = {
3424
- ${screensEntries}
3425
- }
3426
- const ACTIONS: Record<AppRole, '*' | WriteAction[]> = {
3427
- ${actionsEntries}
3428
- }
3429
-
3430
- /** The signed-in user's role. Set it after login (e.g. from the session);
3431
- * defaults to the project's default role. Read it in components as \`$currentRole\`. */
3432
- export const currentRole = writable<AppRole>(${JSON.stringify(defaultRole)})
3433
-
3434
- /** May this role open the given screen id? */
3435
- export function canScreen(role: AppRole, screenId: string): boolean {
3436
- const s = SCREENS[role]
3437
- return s === '*' || (Array.isArray(s) && s.includes(screenId))
3438
- }
3439
- /** May this role perform a write action? (Reads are implied by screen access.) */
3440
- export function can(role: AppRole, action: WriteAction): boolean {
3441
- const a = ACTIONS[role]
3442
- return a === '*' || (Array.isArray(a) && a.includes(action))
3443
- }
3444
-
3445
- // ---- server side ----------------------------------------------------------
3446
- /** Resolve the caller's role on the server. Wire this to YOUR auth: by default it
3447
- * reads \`event.locals.role\` - set it in \`hooks.server.ts\` from the session. */
3448
- export function getServerRole(event: { locals?: { role?: unknown } }): AppRole {
3449
- const r = event?.locals?.role
3450
- return (typeof r === 'string' && (ROLES as string[]).includes(r) ? r : ${JSON.stringify(defaultRole)}) as AppRole
3451
- }
3452
- /** Authorize a CRUD action for a role - used by the API routes' \`authorize\` hook.
3453
- * \`screenIds\` are the screen(s) bound to this route's entity: a read is allowed
3454
- * only if the role can open at least one of them (an entity with no screen of its
3455
- * own - e.g. a relation lookup target - has nothing to gate reads by, so it stays
3456
- * open). Writes are still governed purely by \`can()\`. */
3457
- export function authorizeAction(role: AppRole, action: 'read' | WriteAction, screenIds: string[] = []): boolean {
3458
- if (action !== 'read') return can(role, action)
3459
- if (screenIds.length === 0) return true
3460
- return screenIds.some((id) => canScreen(role, id))
3461
- }
3491
+ contents: `import { writable } from 'svelte/store'
3492
+
3493
+ export type AppRole = ${roleUnion}
3494
+ export type WriteAction = 'create' | 'update' | 'delete'
3495
+ export const ROLES: AppRole[] = ${JSON.stringify(roleNames)} as AppRole[]
3496
+
3497
+ const SCREENS: Record<AppRole, '*' | string[]> = {
3498
+ ${screensEntries}
3499
+ }
3500
+ const ACTIONS: Record<AppRole, '*' | WriteAction[]> = {
3501
+ ${actionsEntries}
3502
+ }
3503
+
3504
+ /** The signed-in user's role. Set it after login (e.g. from the session);
3505
+ * defaults to the project's default role. Read it in components as \`$currentRole\`. */
3506
+ export const currentRole = writable<AppRole>(${JSON.stringify(defaultRole)})
3507
+
3508
+ /** May this role open the given screen id? */
3509
+ export function canScreen(role: AppRole, screenId: string): boolean {
3510
+ const s = SCREENS[role]
3511
+ return s === '*' || (Array.isArray(s) && s.includes(screenId))
3512
+ }
3513
+ /** May this role perform a write action? (Reads are implied by screen access.) */
3514
+ export function can(role: AppRole, action: WriteAction): boolean {
3515
+ const a = ACTIONS[role]
3516
+ return a === '*' || (Array.isArray(a) && a.includes(action))
3517
+ }
3518
+
3519
+ // ---- server side ----------------------------------------------------------
3520
+ /** Resolve the caller's role on the server. Wire this to YOUR auth: by default it
3521
+ * reads \`event.locals.role\` - set it in \`hooks.server.ts\` from the session. */
3522
+ export function getServerRole(event: { locals?: { role?: unknown } }): AppRole {
3523
+ const r = event?.locals?.role
3524
+ return (typeof r === 'string' && (ROLES as string[]).includes(r) ? r : ${JSON.stringify(defaultRole)}) as AppRole
3525
+ }
3526
+ /** Authorize a CRUD action for a role - used by the API routes' \`authorize\` hook.
3527
+ * \`screenIds\` are the screen(s) bound to this route's entity: a read is allowed
3528
+ * only if the role can open at least one of them (an entity with no screen of its
3529
+ * own - e.g. a relation lookup target - has nothing to gate reads by, so it stays
3530
+ * open). Writes are still governed purely by \`can()\`. */
3531
+ export function authorizeAction(role: AppRole, action: 'read' | WriteAction, screenIds: string[] = []): boolean {
3532
+ if (action !== 'read') return can(role, action)
3533
+ if (screenIds.length === 0) return true
3534
+ return screenIds.some((id) => canScreen(role, id))
3535
+ }
3462
3536
  `,
3463
3537
  };
3464
3538
  }
@@ -3489,862 +3563,862 @@ function authFiles(project, dbBacked = false, accessEnabled = false, tenantColum
3489
3563
  const seedLiteral = users
3490
3564
  .map((u) => ` { email: ${JSON.stringify(u.email)}, name: ${JSON.stringify(u.name)}, role: ${JSON.stringify(u.role)}, password: ${JSON.stringify(u.password)} }`)
3491
3565
  .join(',\n');
3492
- const authTs = `// Regenerated by SvGrid Studio. Dependency-free auth: PBKDF2 password hashing +
3493
- // stateless HMAC-signed session cookies (Web Crypto - runs on Node and edge runtimes).
3494
- import { env } from '$env/dynamic/private'
3495
-
3496
- export const SESSION_COOKIE = 'sv_session'
3497
- export const SESSION_MAX_AGE = 60 * 60 * 24 * 7 // 7 days (seconds)
3498
-
3499
- export type SessionUser = { email: string; name: string; role: string }
3500
-
3501
- const enc = new TextEncoder()
3502
- // Signing secret. Set SESSION_SECRET in the environment for production (see .env.example).
3503
- const secret = () => env.SESSION_SECRET || 'dev-insecure-secret-change-me'
3504
-
3505
- function b64url(bytes: ArrayBuffer | Uint8Array): string {
3506
- const b = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
3507
- let s = ''
3508
- for (const byte of b) s += String.fromCharCode(byte)
3509
- return btoa(s).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')
3510
- }
3511
- function fromB64url(s: string): Uint8Array {
3512
- const p = s.replace(/-/g, '+').replace(/_/g, '/')
3513
- const bin = atob(p + '==='.slice((p.length + 3) % 4))
3514
- const out = new Uint8Array(bin.length)
3515
- for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
3516
- return out
3517
- }
3518
- function timingSafeEqual(a: string, b: string): boolean {
3519
- if (a.length !== b.length) return false
3520
- let out = 0
3521
- for (let i = 0; i < a.length; i++) out |= a.charCodeAt(i) ^ b.charCodeAt(i)
3522
- return out === 0
3523
- }
3524
- async function hmac(data: string): Promise<string> {
3525
- const key = await crypto.subtle.importKey('raw', enc.encode(secret()), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
3526
- return b64url(await crypto.subtle.sign('HMAC', key, enc.encode(data)))
3527
- }
3528
-
3529
- /** Sign a stateless session token: base64url(payload).hmac(payload). */
3530
- export async function signSession(user: SessionUser): Promise<string> {
3531
- const payload = { ...user, exp: Math.floor(Date.now() / 1000) + SESSION_MAX_AGE }
3532
- const body = b64url(enc.encode(JSON.stringify(payload)))
3533
- return body + '.' + (await hmac(body))
3534
- }
3535
- /** Verify a token; returns the user, or null if missing / tampered / expired. */
3536
- export async function readSession(token: string | undefined): Promise<SessionUser | null> {
3537
- if (!token) return null
3538
- const dot = token.lastIndexOf('.')
3539
- if (dot < 0) return null
3540
- const body = token.slice(0, dot)
3541
- const sig = token.slice(dot + 1)
3542
- if (!timingSafeEqual(sig, await hmac(body))) return null
3543
- try {
3544
- const p = JSON.parse(new TextDecoder().decode(fromB64url(body))) as SessionUser & { exp: number }
3545
- if (typeof p.exp !== 'number' || p.exp * 1000 < Date.now()) return null
3546
- return { email: p.email, name: p.name, role: p.role }
3547
- } catch {
3548
- return null
3549
- }
3550
- }
3551
-
3552
- // ---- password hashing (PBKDF2) --------------------------------------------
3553
- // Use these to store hashed passwords in a real user store: keep hashPassword()'s
3554
- // output as \`passwordHash\`, then check with verifyPassword(input, passwordHash).
3555
- export async function hashPassword(password: string, salt?: string): Promise<string> {
3556
- const s = salt ?? b64url(crypto.getRandomValues(new Uint8Array(16)))
3557
- const key = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveBits'])
3558
- const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt: enc.encode(s), iterations: 100000, hash: 'SHA-256' }, key, 256)
3559
- return s + ':' + b64url(bits)
3560
- }
3561
- export async function verifyPassword(password: string, stored: string): Promise<boolean> {
3562
- const salt = stored.split(':')[0]
3563
- if (!salt) return false
3564
- return timingSafeEqual(await hashPassword(password, salt), stored)
3565
- }
3566
-
3567
- // ---- password reset (stateless, signed link) ------------------------------
3568
- const RESET_MAX_AGE = 60 * 30 // 30 minutes
3569
- /** A short-lived, signed password-reset token for an email (no DB row needed). */
3570
- export async function signReset(email: string): Promise<string> {
3571
- const body = b64url(enc.encode(JSON.stringify({ email, exp: Math.floor(Date.now() / 1000) + RESET_MAX_AGE, k: 'reset' })))
3572
- return body + '.' + (await hmac(body))
3573
- }
3574
- /** Verify a reset token; returns the email, or null if invalid / expired. */
3575
- export async function readReset(token: string | undefined): Promise<string | null> {
3576
- if (!token) return null
3577
- const dot = token.lastIndexOf('.')
3578
- if (dot < 0) return null
3579
- const body = token.slice(0, dot)
3580
- if (!timingSafeEqual(token.slice(dot + 1), await hmac(body))) return null
3581
- try {
3582
- const p = JSON.parse(new TextDecoder().decode(fromB64url(body))) as { email: string; exp: number; k: string }
3583
- if (p.k !== 'reset' || typeof p.exp !== 'number' || p.exp * 1000 < Date.now()) return null
3584
- return p.email
3585
- } catch {
3586
- return null
3587
- }
3588
- }
3589
-
3590
- /** Deliver a password-reset link. STUB: logs it in dev - wire your email provider
3591
- * (Resend / SendGrid / Postmark / SMTP) here for production. */
3592
- export async function sendResetEmail(email: string, link: string): Promise<void> {
3593
- console.log('[auth] password reset for ' + email + ' -> ' + link)
3594
- }
3595
- ${twoFactor ? `
3596
- // ---- email two-factor challenge (stateless, signed) -----------------------
3597
- export const TFA_COOKIE = 'sv_2fa'
3598
- const TFA_MAX_AGE = 60 * 10 // 10 minutes
3599
- /** A random 6-digit one-time code. */
3600
- export function otpCode(): string {
3601
- return String(crypto.getRandomValues(new Uint32Array(1))[0]! % 1000000).padStart(6, '0')
3602
- }
3603
- async function codeHash(email: string, code: string): Promise<string> {
3604
- const d = await crypto.subtle.digest('SHA-256', enc.encode(email + ':' + code))
3605
- return b64url(d)
3606
- }
3607
- /** A signed pending-2FA token binding an email to a hashed code (the code itself is emailed). */
3608
- export async function signChallenge(email: string, code: string): Promise<string> {
3609
- const body = b64url(enc.encode(JSON.stringify({ email, ch: await codeHash(email, code), exp: Math.floor(Date.now() / 1000) + TFA_MAX_AGE, k: '2fa' })))
3610
- return body + '.' + (await hmac(body))
3611
- }
3612
- /** Verify a submitted code against the pending token; returns the email or null. */
3613
- export async function readChallenge(token: string | undefined, code: string): Promise<string | null> {
3614
- if (!token) return null
3615
- const dot = token.lastIndexOf('.')
3616
- if (dot < 0) return null
3617
- const body = token.slice(0, dot)
3618
- if (!timingSafeEqual(token.slice(dot + 1), await hmac(body))) return null
3619
- try {
3620
- const p = JSON.parse(new TextDecoder().decode(fromB64url(body))) as { email: string; ch: string; exp: number; k: string }
3621
- if (p.k !== '2fa' || typeof p.exp !== 'number' || p.exp * 1000 < Date.now()) return null
3622
- if (!timingSafeEqual(p.ch, await codeHash(p.email, code))) return null
3623
- return p.email
3624
- } catch {
3625
- return null
3626
- }
3627
- }
3566
+ const authTs = `// Regenerated by SvGrid Studio. Dependency-free auth: PBKDF2 password hashing +
3567
+ // stateless HMAC-signed session cookies (Web Crypto - runs on Node and edge runtimes).
3568
+ import { env } from '$env/dynamic/private'
3569
+
3570
+ export const SESSION_COOKIE = 'sv_session'
3571
+ export const SESSION_MAX_AGE = 60 * 60 * 24 * 7 // 7 days (seconds)
3572
+
3573
+ export type SessionUser = { email: string; name: string; role: string }
3574
+
3575
+ const enc = new TextEncoder()
3576
+ // Signing secret. Set SESSION_SECRET in the environment for production (see .env.example).
3577
+ const secret = () => env.SESSION_SECRET || 'dev-insecure-secret-change-me'
3578
+
3579
+ function b64url(bytes: ArrayBuffer | Uint8Array): string {
3580
+ const b = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
3581
+ let s = ''
3582
+ for (const byte of b) s += String.fromCharCode(byte)
3583
+ return btoa(s).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')
3584
+ }
3585
+ function fromB64url(s: string): Uint8Array {
3586
+ const p = s.replace(/-/g, '+').replace(/_/g, '/')
3587
+ const bin = atob(p + '==='.slice((p.length + 3) % 4))
3588
+ const out = new Uint8Array(bin.length)
3589
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
3590
+ return out
3591
+ }
3592
+ function timingSafeEqual(a: string, b: string): boolean {
3593
+ if (a.length !== b.length) return false
3594
+ let out = 0
3595
+ for (let i = 0; i < a.length; i++) out |= a.charCodeAt(i) ^ b.charCodeAt(i)
3596
+ return out === 0
3597
+ }
3598
+ async function hmac(data: string): Promise<string> {
3599
+ const key = await crypto.subtle.importKey('raw', enc.encode(secret()), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
3600
+ return b64url(await crypto.subtle.sign('HMAC', key, enc.encode(data)))
3601
+ }
3602
+
3603
+ /** Sign a stateless session token: base64url(payload).hmac(payload). */
3604
+ export async function signSession(user: SessionUser): Promise<string> {
3605
+ const payload = { ...user, exp: Math.floor(Date.now() / 1000) + SESSION_MAX_AGE }
3606
+ const body = b64url(enc.encode(JSON.stringify(payload)))
3607
+ return body + '.' + (await hmac(body))
3608
+ }
3609
+ /** Verify a token; returns the user, or null if missing / tampered / expired. */
3610
+ export async function readSession(token: string | undefined): Promise<SessionUser | null> {
3611
+ if (!token) return null
3612
+ const dot = token.lastIndexOf('.')
3613
+ if (dot < 0) return null
3614
+ const body = token.slice(0, dot)
3615
+ const sig = token.slice(dot + 1)
3616
+ if (!timingSafeEqual(sig, await hmac(body))) return null
3617
+ try {
3618
+ const p = JSON.parse(new TextDecoder().decode(fromB64url(body))) as SessionUser & { exp: number }
3619
+ if (typeof p.exp !== 'number' || p.exp * 1000 < Date.now()) return null
3620
+ return { email: p.email, name: p.name, role: p.role }
3621
+ } catch {
3622
+ return null
3623
+ }
3624
+ }
3625
+
3626
+ // ---- password hashing (PBKDF2) --------------------------------------------
3627
+ // Use these to store hashed passwords in a real user store: keep hashPassword()'s
3628
+ // output as \`passwordHash\`, then check with verifyPassword(input, passwordHash).
3629
+ export async function hashPassword(password: string, salt?: string): Promise<string> {
3630
+ const s = salt ?? b64url(crypto.getRandomValues(new Uint8Array(16)))
3631
+ const key = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveBits'])
3632
+ const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt: enc.encode(s), iterations: 100000, hash: 'SHA-256' }, key, 256)
3633
+ return s + ':' + b64url(bits)
3634
+ }
3635
+ export async function verifyPassword(password: string, stored: string): Promise<boolean> {
3636
+ const salt = stored.split(':')[0]
3637
+ if (!salt) return false
3638
+ return timingSafeEqual(await hashPassword(password, salt), stored)
3639
+ }
3640
+
3641
+ // ---- password reset (stateless, signed link) ------------------------------
3642
+ const RESET_MAX_AGE = 60 * 30 // 30 minutes
3643
+ /** A short-lived, signed password-reset token for an email (no DB row needed). */
3644
+ export async function signReset(email: string): Promise<string> {
3645
+ const body = b64url(enc.encode(JSON.stringify({ email, exp: Math.floor(Date.now() / 1000) + RESET_MAX_AGE, k: 'reset' })))
3646
+ return body + '.' + (await hmac(body))
3647
+ }
3648
+ /** Verify a reset token; returns the email, or null if invalid / expired. */
3649
+ export async function readReset(token: string | undefined): Promise<string | null> {
3650
+ if (!token) return null
3651
+ const dot = token.lastIndexOf('.')
3652
+ if (dot < 0) return null
3653
+ const body = token.slice(0, dot)
3654
+ if (!timingSafeEqual(token.slice(dot + 1), await hmac(body))) return null
3655
+ try {
3656
+ const p = JSON.parse(new TextDecoder().decode(fromB64url(body))) as { email: string; exp: number; k: string }
3657
+ if (p.k !== 'reset' || typeof p.exp !== 'number' || p.exp * 1000 < Date.now()) return null
3658
+ return p.email
3659
+ } catch {
3660
+ return null
3661
+ }
3662
+ }
3663
+
3664
+ /** Deliver a password-reset link. STUB: logs it in dev - wire your email provider
3665
+ * (Resend / SendGrid / Postmark / SMTP) here for production. */
3666
+ export async function sendResetEmail(email: string, link: string): Promise<void> {
3667
+ console.log('[auth] password reset for ' + email + ' -> ' + link)
3668
+ }
3669
+ ${twoFactor ? `
3670
+ // ---- email two-factor challenge (stateless, signed) -----------------------
3671
+ export const TFA_COOKIE = 'sv_2fa'
3672
+ const TFA_MAX_AGE = 60 * 10 // 10 minutes
3673
+ /** A random 6-digit one-time code. */
3674
+ export function otpCode(): string {
3675
+ return String(crypto.getRandomValues(new Uint32Array(1))[0]! % 1000000).padStart(6, '0')
3676
+ }
3677
+ async function codeHash(email: string, code: string): Promise<string> {
3678
+ const d = await crypto.subtle.digest('SHA-256', enc.encode(email + ':' + code))
3679
+ return b64url(d)
3680
+ }
3681
+ /** A signed pending-2FA token binding an email to a hashed code (the code itself is emailed). */
3682
+ export async function signChallenge(email: string, code: string): Promise<string> {
3683
+ const body = b64url(enc.encode(JSON.stringify({ email, ch: await codeHash(email, code), exp: Math.floor(Date.now() / 1000) + TFA_MAX_AGE, k: '2fa' })))
3684
+ return body + '.' + (await hmac(body))
3685
+ }
3686
+ /** Verify a submitted code against the pending token; returns the email or null. */
3687
+ export async function readChallenge(token: string | undefined, code: string): Promise<string | null> {
3688
+ if (!token) return null
3689
+ const dot = token.lastIndexOf('.')
3690
+ if (dot < 0) return null
3691
+ const body = token.slice(0, dot)
3692
+ if (!timingSafeEqual(token.slice(dot + 1), await hmac(body))) return null
3693
+ try {
3694
+ const p = JSON.parse(new TextDecoder().decode(fromB64url(body))) as { email: string; ch: string; exp: number; k: string }
3695
+ if (p.k !== '2fa' || typeof p.exp !== 'number' || p.exp * 1000 < Date.now()) return null
3696
+ if (!timingSafeEqual(p.ch, await codeHash(p.email, code))) return null
3697
+ return p.email
3698
+ } catch {
3699
+ return null
3700
+ }
3701
+ }
3628
3702
  ` : ''}`;
3629
3703
  const usersTs = dbBacked
3630
- ? `// Regenerated by SvGrid Studio. DB-backed user store: reads the \`auth_users\` table
3631
- // (Drizzle) with hashed passwords. The demo users below are seeded ONCE, on the first
3632
- // login, if the table is empty - delete the seed for production, or manage users in the DB.
3633
- import { eq } from 'drizzle-orm'
3634
- import { db } from './db/index'
3635
- import { authUsers, type AuthUserRow } from './db/schema'
3636
- import { hashPassword } from './auth'
3637
-
3638
- export type AppUser = AuthUserRow
3639
-
3640
- const SEED: Array<{ email: string; name: string; role: string; password: string }> = [
3641
- ${seedLiteral},
3642
- ]
3643
-
3644
- // Seed the demo users once (idempotent + concurrency-safe: the unique email index
3645
- // rejects a racing duplicate, which we swallow). Cached per process after the first run.
3646
- let seeding: Promise<void> | null = null
3647
- function ensureSeeded(): Promise<void> {
3648
- if (!seeding) seeding = (async () => {
3649
- const existing = await db.select({ email: authUsers.email }).from(authUsers).limit(1)
3650
- if (existing.length) return
3651
- for (const u of SEED) {
3652
- try {
3653
- await db.insert(authUsers).values({ email: u.email, name: u.name, role: u.role, passwordHash: await hashPassword(u.password) })
3654
- } catch { /* unique-email race: another request seeded it first */ }
3655
- }
3656
- })()
3657
- return seeding
3658
- }
3659
-
3660
- export async function findUser(email: string): Promise<AppUser | undefined> {
3661
- await ensureSeeded()
3662
- const e = email.trim().toLowerCase()
3663
- return (await db.select().from(authUsers).where(eq(authUsers.email, e))).at(0)
3664
- }
3665
-
3666
- /** Public view of a user (no password hash) - for the admin list. */
3667
- export type PublicUser = { email: string; name: string; role: string }
3668
- export async function listUsers(): Promise<PublicUser[]> {
3669
- await ensureSeeded()
3670
- return db.select({ email: authUsers.email, name: authUsers.name, role: authUsers.role }).from(authUsers)
3671
- }
3672
-
3673
- /** Create a user (hashed password). Returns undefined if the email already exists. */
3674
- export async function createUser(input: { email: string; name: string; password: string; role: string }): Promise<AppUser | undefined> {
3675
- const email = input.email.trim().toLowerCase()
3676
- if (await findUser(email)) return undefined
3677
- const passwordHash = await hashPassword(input.password)
3678
- try {
3679
- await db.insert(authUsers).values({ email, name: input.name, role: input.role, passwordHash })
3680
- } catch {
3681
- return undefined // unique-email race
3682
- }
3683
- return findUser(email)
3684
- }
3685
-
3686
- export async function updatePassword(email: string, newPassword: string): Promise<void> {
3687
- await db.update(authUsers).set({ passwordHash: await hashPassword(newPassword) }).where(eq(authUsers.email, email.trim().toLowerCase()))
3688
- }
3689
- export async function setUserRole(email: string, role: string): Promise<void> {
3690
- await db.update(authUsers).set({ role }).where(eq(authUsers.email, email.trim().toLowerCase()))
3691
- }
3692
- export async function deleteUser(email: string): Promise<void> {
3693
- await db.delete(authUsers).where(eq(authUsers.email, email.trim().toLowerCase()))
3694
- }${twoFactor ? `
3695
- export async function setTwoFactor(email: string, on: boolean): Promise<void> {
3696
- await db.update(authUsers).set({ twoFactor: on }).where(eq(authUsers.email, email.trim().toLowerCase()))
3697
- }` : ''}
3704
+ ? `// Regenerated by SvGrid Studio. DB-backed user store: reads the \`auth_users\` table
3705
+ // (Drizzle) with hashed passwords. The demo users below are seeded ONCE, on the first
3706
+ // login, if the table is empty - delete the seed for production, or manage users in the DB.
3707
+ import { eq } from 'drizzle-orm'
3708
+ import { db } from './db/index'
3709
+ import { authUsers, type AuthUserRow } from './db/schema'
3710
+ import { hashPassword } from './auth'
3711
+
3712
+ export type AppUser = AuthUserRow
3713
+
3714
+ const SEED: Array<{ email: string; name: string; role: string; password: string }> = [
3715
+ ${seedLiteral},
3716
+ ]
3717
+
3718
+ // Seed the demo users once (idempotent + concurrency-safe: the unique email index
3719
+ // rejects a racing duplicate, which we swallow). Cached per process after the first run.
3720
+ let seeding: Promise<void> | null = null
3721
+ function ensureSeeded(): Promise<void> {
3722
+ if (!seeding) seeding = (async () => {
3723
+ const existing = await db.select({ email: authUsers.email }).from(authUsers).limit(1)
3724
+ if (existing.length) return
3725
+ for (const u of SEED) {
3726
+ try {
3727
+ await db.insert(authUsers).values({ email: u.email, name: u.name, role: u.role, passwordHash: await hashPassword(u.password) })
3728
+ } catch { /* unique-email race: another request seeded it first */ }
3729
+ }
3730
+ })()
3731
+ return seeding
3732
+ }
3733
+
3734
+ export async function findUser(email: string): Promise<AppUser | undefined> {
3735
+ await ensureSeeded()
3736
+ const e = email.trim().toLowerCase()
3737
+ return (await db.select().from(authUsers).where(eq(authUsers.email, e))).at(0)
3738
+ }
3739
+
3740
+ /** Public view of a user (no password hash) - for the admin list. */
3741
+ export type PublicUser = { email: string; name: string; role: string }
3742
+ export async function listUsers(): Promise<PublicUser[]> {
3743
+ await ensureSeeded()
3744
+ return db.select({ email: authUsers.email, name: authUsers.name, role: authUsers.role }).from(authUsers)
3745
+ }
3746
+
3747
+ /** Create a user (hashed password). Returns undefined if the email already exists. */
3748
+ export async function createUser(input: { email: string; name: string; password: string; role: string }): Promise<AppUser | undefined> {
3749
+ const email = input.email.trim().toLowerCase()
3750
+ if (await findUser(email)) return undefined
3751
+ const passwordHash = await hashPassword(input.password)
3752
+ try {
3753
+ await db.insert(authUsers).values({ email, name: input.name, role: input.role, passwordHash })
3754
+ } catch {
3755
+ return undefined // unique-email race
3756
+ }
3757
+ return findUser(email)
3758
+ }
3759
+
3760
+ export async function updatePassword(email: string, newPassword: string): Promise<void> {
3761
+ await db.update(authUsers).set({ passwordHash: await hashPassword(newPassword) }).where(eq(authUsers.email, email.trim().toLowerCase()))
3762
+ }
3763
+ export async function setUserRole(email: string, role: string): Promise<void> {
3764
+ await db.update(authUsers).set({ role }).where(eq(authUsers.email, email.trim().toLowerCase()))
3765
+ }
3766
+ export async function deleteUser(email: string): Promise<void> {
3767
+ await db.delete(authUsers).where(eq(authUsers.email, email.trim().toLowerCase()))
3768
+ }${twoFactor ? `
3769
+ export async function setTwoFactor(email: string, on: boolean): Promise<void> {
3770
+ await db.update(authUsers).set({ twoFactor: on }).where(eq(authUsers.email, email.trim().toLowerCase()))
3771
+ }` : ''}
3698
3772
  `
3699
- : `// Regenerated by SvGrid Studio. DEMO user store - replace with your own (a DB table,
3700
- // an external identity provider, ...). The seed passwords below are demo defaults
3701
- // (printed on the login page); they are HASHED (PBKDF2) at startup and never compared
3702
- // in plaintext. Change them for anything real. Tip: turn on the Drizzle data layer and
3703
- // this becomes a real \`auth_users\` DB table.
3704
- import { hashPassword } from './auth'
3705
-
3706
- export type AppUser = { email: string; name: string; role: string; passwordHash: string }
3707
-
3708
- const SEED: Array<{ email: string; name: string; role: string; password: string }> = [
3709
- ${usersLiteral},
3710
- ]
3711
-
3712
- // Hash the seed passwords once at module load (top-level await) so the store holds
3713
- // only PBKDF2 hashes - login verifies with verifyPassword(), never a plaintext compare.
3714
- export const USERS: AppUser[] = await Promise.all(
3715
- SEED.map(async (u) => ({ email: u.email, name: u.name, role: u.role, passwordHash: await hashPassword(u.password) })),
3716
- )
3717
-
3718
- export function findUser(email: string): AppUser | undefined {
3719
- const e = email.trim().toLowerCase()
3720
- return USERS.find((u) => u.email.toLowerCase() === e)
3721
- }
3773
+ : `// Regenerated by SvGrid Studio. DEMO user store - replace with your own (a DB table,
3774
+ // an external identity provider, ...). The seed passwords below are demo defaults
3775
+ // (printed on the login page); they are HASHED (PBKDF2) at startup and never compared
3776
+ // in plaintext. Change them for anything real. Tip: turn on the Drizzle data layer and
3777
+ // this becomes a real \`auth_users\` DB table.
3778
+ import { hashPassword } from './auth'
3779
+
3780
+ export type AppUser = { email: string; name: string; role: string; passwordHash: string }
3781
+
3782
+ const SEED: Array<{ email: string; name: string; role: string; password: string }> = [
3783
+ ${usersLiteral},
3784
+ ]
3785
+
3786
+ // Hash the seed passwords once at module load (top-level await) so the store holds
3787
+ // only PBKDF2 hashes - login verifies with verifyPassword(), never a plaintext compare.
3788
+ export const USERS: AppUser[] = await Promise.all(
3789
+ SEED.map(async (u) => ({ email: u.email, name: u.name, role: u.role, passwordHash: await hashPassword(u.password) })),
3790
+ )
3791
+
3792
+ export function findUser(email: string): AppUser | undefined {
3793
+ const e = email.trim().toLowerCase()
3794
+ return USERS.find((u) => u.email.toLowerCase() === e)
3795
+ }
3722
3796
  `;
3723
- const hooksTs = `import type { Handle } from '@sveltejs/kit'
3724
- import { SESSION_COOKIE, readSession } from '../lib/server/auth'
3725
-
3726
- // Resolve the signed session on every request into event.locals - the RBAC layer
3727
- // (getServerRole / authorize) and the app shell read event.locals.role from here.
3728
- export const handle: Handle = async ({ event, resolve }) => {
3729
- const user = await readSession(event.cookies.get(SESSION_COOKIE))
3730
- event.locals.user = user ?? undefined
3731
- event.locals.role = user?.role${tenantColumn ? `
3732
- // Multi-tenancy: the tenant travels on the session, so every scoped API route
3733
- // reads it from here rather than trusting anything the client sends.
3734
- event.locals.${tenantColumn} = (user as { ${tenantColumn}?: string } | null | undefined)?.${tenantColumn}` : ''}
3735
- return resolve(event)
3736
- }
3797
+ const hooksTs = `import type { Handle } from '@sveltejs/kit'
3798
+ import { SESSION_COOKIE, readSession } from '../lib/server/auth'
3799
+
3800
+ // Resolve the signed session on every request into event.locals - the RBAC layer
3801
+ // (getServerRole / authorize) and the app shell read event.locals.role from here.
3802
+ export const handle: Handle = async ({ event, resolve }) => {
3803
+ const user = await readSession(event.cookies.get(SESSION_COOKIE))
3804
+ event.locals.user = user ?? undefined
3805
+ event.locals.role = user?.role${tenantColumn ? `
3806
+ // Multi-tenancy: the tenant travels on the session, so every scoped API route
3807
+ // reads it from here rather than trusting anything the client sends.
3808
+ event.locals.${tenantColumn} = (user as { ${tenantColumn}?: string } | null | undefined)?.${tenantColumn}` : ''}
3809
+ return resolve(event)
3810
+ }
3737
3811
  `;
3738
- const localsDts = `import type { SessionUser } from '../lib/server/auth'
3739
-
3740
- declare global {
3741
- namespace App {
3742
- interface Locals {
3743
- user?: SessionUser
3744
- role?: string
3745
- }
3746
- }
3747
- }
3748
-
3749
- export {}
3812
+ const localsDts = `import type { SessionUser } from '../lib/server/auth'
3813
+
3814
+ declare global {
3815
+ namespace App {
3816
+ interface Locals {
3817
+ user?: SessionUser
3818
+ role?: string
3819
+ }
3820
+ }
3821
+ }
3822
+
3823
+ export {}
3750
3824
  `;
3751
3825
  // Routes that render bare (no app shell) and don't require a session.
3752
3826
  const publicRoutes = ['/login', ...(twoFactor ? ['/login/verify'] : []), ...(register ? ['/register', '/forgot-password', '/reset-password'] : [])];
3753
- const layoutServerTs = `import type { LayoutServerLoad } from './$types'
3754
- ${protect ? `import { redirect } from '@sveltejs/kit'\n\nconst PUBLIC = new Set(${JSON.stringify(publicRoutes)})\n` : ''}
3755
- // Expose the signed-in user + role to every page (read as \`data.user\` / \`data.role\`,
3756
- // or \`page.data\`).${protect ? ' Unauthenticated visitors are sent to /login.' : ''}
3757
- export const load: LayoutServerLoad = async ({ locals${protect ? ', url' : ''} }) => {
3758
- ${protect ? " if (!locals.user && !PUBLIC.has(url.pathname)) throw redirect(302, '/login?redirectTo=' + encodeURIComponent(url.pathname))\n" : ''} return { user: locals.user ?? null, role: locals.role ?? null }
3759
- }
3827
+ const layoutServerTs = `import type { LayoutServerLoad } from './$types'
3828
+ ${protect ? `import { redirect } from '@sveltejs/kit'\n\nconst PUBLIC = new Set(${JSON.stringify(publicRoutes)})\n` : ''}
3829
+ // Expose the signed-in user + role to every page (read as \`data.user\` / \`data.role\`,
3830
+ // or \`page.data\`).${protect ? ' Unauthenticated visitors are sent to /login.' : ''}
3831
+ export const load: LayoutServerLoad = async ({ locals${protect ? ', url' : ''} }) => {
3832
+ ${protect ? " if (!locals.user && !PUBLIC.has(url.pathname)) throw redirect(302, '/login?redirectTo=' + encodeURIComponent(url.pathname))\n" : ''} return { user: locals.user ?? null, role: locals.role ?? null }
3833
+ }
3760
3834
  `;
3761
3835
  const loginCheck = dbBacked
3762
- ? `const user = await findUser(email)
3763
- // DB-backed store: verify against the stored PBKDF2 hash.
3836
+ ? `const user = await findUser(email)
3837
+ // DB-backed store: verify against the stored PBKDF2 hash.
3764
3838
  if (!user || !(await verifyPassword(password, user.passwordHash))) return fail(401, { email, error: 'Invalid email or password.' })`
3765
- : `const user = findUser(email)
3766
- // Verify against the PBKDF2 hash of the seed (users store hashes it at startup).
3839
+ : `const user = findUser(email)
3840
+ // Verify against the PBKDF2 hash of the seed (users store hashes it at startup).
3767
3841
  if (!user || !(await verifyPassword(password, user.passwordHash))) return fail(401, { email, error: 'Invalid email or password.' })`;
3768
3842
  // 2FA (email code): after the password check, email a code + park a pending token,
3769
3843
  // then send the user to /login/verify instead of issuing the session immediately.
3770
- const twoFactorBranch = twoFactor ? `
3771
- if (user.twoFactor) {
3772
- const code = otpCode()
3773
- await sendEmail(user.email, 'Your verification code', '<p>Your sign-in code is <strong>' + code + '</strong>. It expires in 10 minutes.</p>')
3774
- cookies.set(TFA_COOKIE, await signChallenge(user.email, code), { path: '/', httpOnly: true, sameSite: 'lax', secure: url.hostname !== 'localhost' && url.hostname !== '127.0.0.1', maxAge: 600 })
3775
- const rt = url.searchParams.get('redirectTo')
3776
- throw redirect(302, '/login/verify' + (rt ? '?redirectTo=' + encodeURIComponent(rt) : ''))
3844
+ const twoFactorBranch = twoFactor ? `
3845
+ if (user.twoFactor) {
3846
+ const code = otpCode()
3847
+ await sendEmail(user.email, 'Your verification code', '<p>Your sign-in code is <strong>' + code + '</strong>. It expires in 10 minutes.</p>')
3848
+ cookies.set(TFA_COOKIE, await signChallenge(user.email, code), { path: '/', httpOnly: true, sameSite: 'lax', secure: url.hostname !== 'localhost' && url.hostname !== '127.0.0.1', maxAge: 600 })
3849
+ const rt = url.searchParams.get('redirectTo')
3850
+ throw redirect(302, '/login/verify' + (rt ? '?redirectTo=' + encodeURIComponent(rt) : ''))
3777
3851
  }` : '';
3778
3852
  const loginImports = twoFactor
3779
3853
  ? "import { SESSION_COOKIE, SESSION_MAX_AGE, TFA_COOKIE, signSession, verifyPassword, otpCode, signChallenge } from '../lib/server/auth'\nimport { findUser } from '$lib/server/users'\nimport { sendEmail } from '$lib/server/email'"
3780
3854
  : `import { SESSION_COOKIE, SESSION_MAX_AGE, signSession, verifyPassword } from '../lib/server/auth'\nimport { findUser } from '$lib/server/users'`;
3781
- const loginServerTs = `import { fail, redirect } from '@sveltejs/kit'
3782
- import type { Actions, PageServerLoad } from './$types'
3783
- ${loginImports}
3784
-
3785
- export const load: PageServerLoad = async ({ locals }) => {
3786
- if (locals.user) throw redirect(302, '/')
3787
- return {}
3788
- }
3789
-
3790
- export const actions: Actions = {
3791
- default: async ({ request, cookies, url }) => {
3792
- const form = await request.formData()
3793
- const email = String(form.get('email') ?? '')
3794
- const password = String(form.get('password') ?? '')
3795
- ${loginCheck}${twoFactorBranch}
3796
- const token = await signSession({ email: user.email, name: user.name, role: user.role })
3797
- cookies.set(SESSION_COOKIE, token, {
3798
- path: '/', httpOnly: true, sameSite: 'lax',
3799
- secure: url.hostname !== 'localhost' && url.hostname !== '127.0.0.1',
3800
- maxAge: SESSION_MAX_AGE,
3801
- })
3802
- throw redirect(302, url.searchParams.get('redirectTo') || '/')
3803
- },
3804
- }
3855
+ const loginServerTs = `import { fail, redirect } from '@sveltejs/kit'
3856
+ import type { Actions, PageServerLoad } from './$types'
3857
+ ${loginImports}
3858
+
3859
+ export const load: PageServerLoad = async ({ locals }) => {
3860
+ if (locals.user) throw redirect(302, '/')
3861
+ return {}
3862
+ }
3863
+
3864
+ export const actions: Actions = {
3865
+ default: async ({ request, cookies, url }) => {
3866
+ const form = await request.formData()
3867
+ const email = String(form.get('email') ?? '')
3868
+ const password = String(form.get('password') ?? '')
3869
+ ${loginCheck}${twoFactorBranch}
3870
+ const token = await signSession({ email: user.email, name: user.name, role: user.role })
3871
+ cookies.set(SESSION_COOKIE, token, {
3872
+ path: '/', httpOnly: true, sameSite: 'lax',
3873
+ secure: url.hostname !== 'localhost' && url.hostname !== '127.0.0.1',
3874
+ maxAge: SESSION_MAX_AGE,
3875
+ })
3876
+ throw redirect(302, url.searchParams.get('redirectTo') || '/')
3877
+ },
3878
+ }
3805
3879
  `;
3806
3880
  // Shared full-screen auth-page styles (login / register / forgot / reset).
3807
- const authStyle = `<style>
3808
- .auth { display: grid; place-items: center; min-height: 100vh; padding: 24px; background: var(--sg-bg-subtle, #f8fafc); }
3809
- .auth__card { width: 100%; max-width: 360px; display: flex; flex-direction: column; gap: 12px; padding: 28px; background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 16px; box-shadow: 0 12px 40px -12px rgba(15, 23, 42, 0.18); }
3810
- .auth__title { margin: 0; font-size: 22px; font-weight: 720; letter-spacing: -0.02em; }
3811
- .auth__sub { margin: -6px 0 6px; font-size: 13.5px; color: var(--sg-muted, #64748b); }
3812
- .auth__field { display: flex; flex-direction: column; gap: 5px; font-size: 12.5px; font-weight: 600; color: var(--sg-muted, #64748b); }
3813
- .auth__field input { padding: 9px 11px; font: inherit; font-size: 14px; font-weight: 400; color: var(--sg-fg, #0f172a); background: var(--sg-input-bg, #fff); border: 1px solid var(--sg-input-border, #e6e8ec); border-radius: 9px; }
3814
- .auth__field input:focus { outline: none; border-color: var(--sg-accent, #6366f1); box-shadow: 0 0 0 3px color-mix(in srgb, var(--sg-accent, #6366f1) 18%, transparent); }
3815
- .auth__btn { margin-top: 4px; padding: 10px; font: inherit; font-size: 14px; font-weight: 640; color: var(--sg-on-accent, #fff); background: var(--sg-accent, #6366f1); border: none; border-radius: 9px; cursor: pointer; }
3816
- .auth__btn:hover { filter: brightness(1.06); }
3817
- .auth__err { margin: 0; padding: 8px 11px; font-size: 13px; color: var(--sg-danger, #b3261e); background: color-mix(in srgb, var(--sg-danger, #dc2626) 9%, var(--sg-bg, #fff)); border: 1px solid color-mix(in srgb, var(--sg-danger, #dc2626) 35%, var(--sg-border, #e6e8ec)); border-radius: 9px; }
3818
- .auth__ok { margin: 0; padding: 8px 11px; font-size: 13px; color: #166534; background: color-mix(in srgb, #16a34a 9%, var(--sg-bg, #fff)); border: 1px solid color-mix(in srgb, #16a34a 35%, var(--sg-border, #e6e8ec)); border-radius: 9px; }
3819
- .auth__hint { margin: 6px 0 0; font-size: 12px; color: var(--sg-muted, #94a3b8); text-align: center; }
3820
- .auth__hint code { background: color-mix(in srgb, var(--sg-fg, #0f172a) 6%, transparent); padding: 1px 5px; border-radius: 5px; }
3821
- .auth__links { margin: 2px 0 0; display: flex; justify-content: space-between; font-size: 12.5px; }
3822
- .auth__links a { color: var(--sg-accent, #6366f1); text-decoration: none; font-weight: 600; }
3823
- .auth__or { display: flex; align-items: center; gap: 10px; margin: 2px 0; color: var(--sg-muted, #94a3b8); font-size: 12px; }
3824
- .auth__or::before, .auth__or::after { content: ''; flex: 1; height: 1px; background: var(--sg-border, #e6e8ec); }
3825
- .auth__oauth { display: flex; flex-direction: column; gap: 8px; }
3826
- .auth__oauth-btn { display: block; text-align: center; padding: 9px; font-size: 13.5px; font-weight: 600; color: var(--sg-fg, #0f172a); background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 9px; text-decoration: none; }
3827
- .auth__oauth-btn:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 4%, var(--sg-bg, #fff)); }
3881
+ const authStyle = `<style>
3882
+ .auth { display: grid; place-items: center; min-height: 100vh; padding: 24px; background: var(--sg-bg-subtle, #f8fafc); }
3883
+ .auth__card { width: 100%; max-width: 360px; display: flex; flex-direction: column; gap: 12px; padding: 28px; background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 16px; box-shadow: 0 12px 40px -12px rgba(15, 23, 42, 0.18); }
3884
+ .auth__title { margin: 0; font-size: 22px; font-weight: 720; letter-spacing: -0.02em; }
3885
+ .auth__sub { margin: -6px 0 6px; font-size: 13.5px; color: var(--sg-muted, #64748b); }
3886
+ .auth__field { display: flex; flex-direction: column; gap: 5px; font-size: 12.5px; font-weight: 600; color: var(--sg-muted, #64748b); }
3887
+ .auth__field input { padding: 9px 11px; font: inherit; font-size: 14px; font-weight: 400; color: var(--sg-fg, #0f172a); background: var(--sg-input-bg, #fff); border: 1px solid var(--sg-input-border, #e6e8ec); border-radius: 9px; }
3888
+ .auth__field input:focus { outline: none; border-color: var(--sg-accent, #6366f1); box-shadow: 0 0 0 3px color-mix(in srgb, var(--sg-accent, #6366f1) 18%, transparent); }
3889
+ .auth__btn { margin-top: 4px; padding: 10px; font: inherit; font-size: 14px; font-weight: 640; color: var(--sg-on-accent, #fff); background: var(--sg-accent, #6366f1); border: none; border-radius: 9px; cursor: pointer; }
3890
+ .auth__btn:hover { filter: brightness(1.06); }
3891
+ .auth__err { margin: 0; padding: 8px 11px; font-size: 13px; color: var(--sg-danger, #b3261e); background: color-mix(in srgb, var(--sg-danger, #dc2626) 9%, var(--sg-bg, #fff)); border: 1px solid color-mix(in srgb, var(--sg-danger, #dc2626) 35%, var(--sg-border, #e6e8ec)); border-radius: 9px; }
3892
+ .auth__ok { margin: 0; padding: 8px 11px; font-size: 13px; color: #166534; background: color-mix(in srgb, #16a34a 9%, var(--sg-bg, #fff)); border: 1px solid color-mix(in srgb, #16a34a 35%, var(--sg-border, #e6e8ec)); border-radius: 9px; }
3893
+ .auth__hint { margin: 6px 0 0; font-size: 12px; color: var(--sg-muted, #94a3b8); text-align: center; }
3894
+ .auth__hint code { background: color-mix(in srgb, var(--sg-fg, #0f172a) 6%, transparent); padding: 1px 5px; border-radius: 5px; }
3895
+ .auth__links { margin: 2px 0 0; display: flex; justify-content: space-between; font-size: 12.5px; }
3896
+ .auth__links a { color: var(--sg-accent, #6366f1); text-decoration: none; font-weight: 600; }
3897
+ .auth__or { display: flex; align-items: center; gap: 10px; margin: 2px 0; color: var(--sg-muted, #94a3b8); font-size: 12px; }
3898
+ .auth__or::before, .auth__or::after { content: ''; flex: 1; height: 1px; background: var(--sg-border, #e6e8ec); }
3899
+ .auth__oauth { display: flex; flex-direction: column; gap: 8px; }
3900
+ .auth__oauth-btn { display: block; text-align: center; padding: 9px; font-size: 13.5px; font-weight: 600; color: var(--sg-fg, #0f172a); background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 9px; text-decoration: none; }
3901
+ .auth__oauth-btn:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 4%, var(--sg-bg, #fff)); }
3828
3902
  </style>`;
3829
- const loginPage = `<script lang="ts">
3830
- import { enhance } from '$app/forms'
3831
- let { form } = $props()
3832
- </script>
3833
-
3834
- <div class="auth">
3835
- <form method="POST" use:enhance class="auth__card">
3836
- <h1 class="auth__title">Sign in</h1>
3837
- <p class="auth__sub">${jsStrHtml(project.title || 'Welcome back')}</p>
3838
- {#if form?.error}<p class="auth__err" role="alert">{form.error}</p>{/if}
3839
- <label class="auth__field"><span>Email</span>
3840
- <input name="email" type="email" autocomplete="username" value={form?.email ?? ''} required />
3841
- </label>
3842
- <label class="auth__field"><span>Password</span>
3843
- <input name="password" type="password" autocomplete="current-password" required />
3844
- </label>
3845
- <button class="auth__btn" type="submit">Sign in</button>${oauthProviders.length ? `
3846
- <div class="auth__or"><span>or</span></div>
3847
- <div class="auth__oauth">
3848
- ${oauthProviders.map((p) => `<a class="auth__oauth-btn" href="/auth/${p}">Continue with ${{ github: 'GitHub', google: 'Google', oidc: 'SSO' }[p]}</a>`).join('\n ')}
3849
- </div>` : ''}
3850
- <p class="auth__hint">Demo account: <code>${demo.email}</code> / <code>${demo.password}</code></p>${register ? `
3851
- <p class="auth__links"><a href="/forgot-password">Forgot password?</a><a href="/register">Create account</a></p>` : ''}
3852
- </form>
3853
- </div>
3854
-
3855
- ${authStyle}
3903
+ const loginPage = `<script lang="ts">
3904
+ import { enhance } from '$app/forms'
3905
+ let { form } = $props()
3906
+ </script>
3907
+
3908
+ <div class="auth">
3909
+ <form method="POST" use:enhance class="auth__card">
3910
+ <h1 class="auth__title">Sign in</h1>
3911
+ <p class="auth__sub">${jsStrHtml(project.title || 'Welcome back')}</p>
3912
+ {#if form?.error}<p class="auth__err" role="alert">{form.error}</p>{/if}
3913
+ <label class="auth__field"><span>Email</span>
3914
+ <input name="email" type="email" autocomplete="username" value={form?.email ?? ''} required />
3915
+ </label>
3916
+ <label class="auth__field"><span>Password</span>
3917
+ <input name="password" type="password" autocomplete="current-password" required />
3918
+ </label>
3919
+ <button class="auth__btn" type="submit">Sign in</button>${oauthProviders.length ? `
3920
+ <div class="auth__or"><span>or</span></div>
3921
+ <div class="auth__oauth">
3922
+ ${oauthProviders.map((p) => `<a class="auth__oauth-btn" href="/auth/${p}">Continue with ${{ github: 'GitHub', google: 'Google', oidc: 'SSO' }[p]}</a>`).join('\n ')}
3923
+ </div>` : ''}
3924
+ <p class="auth__hint">Demo account: <code>${demo.email}</code> / <code>${demo.password}</code></p>${register ? `
3925
+ <p class="auth__links"><a href="/forgot-password">Forgot password?</a><a href="/register">Create account</a></p>` : ''}
3926
+ </form>
3927
+ </div>
3928
+
3929
+ ${authStyle}
3856
3930
  `;
3857
- const logoutServerTs = `import { redirect } from '@sveltejs/kit'
3858
- import type { Actions } from './$types'
3859
- import { SESSION_COOKIE } from '../lib/server/auth'
3860
-
3861
- export const actions: Actions = {
3862
- default: async ({ cookies }) => {
3863
- cookies.delete(SESSION_COOKIE, { path: '/' })
3864
- throw redirect(302, '/login')
3865
- },
3866
- }
3931
+ const logoutServerTs = `import { redirect } from '@sveltejs/kit'
3932
+ import type { Actions } from './$types'
3933
+ import { SESSION_COOKIE } from '../lib/server/auth'
3934
+
3935
+ export const actions: Actions = {
3936
+ default: async ({ cookies }) => {
3937
+ cookies.delete(SESSION_COOKIE, { path: '/' })
3938
+ throw redirect(302, '/login')
3939
+ },
3940
+ }
3867
3941
  `;
3868
3942
  // --- change password (any signed-in user; DB-backed only) ---
3869
- const accountServerTs = `import { fail, redirect } from '@sveltejs/kit'
3870
- import type { Actions, PageServerLoad } from './$types'
3871
- import { findUser, updatePassword${twoFactor ? ', setTwoFactor' : ''} } from '$lib/server/users'
3872
- import { verifyPassword } from '../lib/server/auth'
3873
-
3874
- export const load: PageServerLoad = async ({ locals }) => {
3875
- if (!locals.user) throw redirect(302, '/login')
3876
- ${twoFactor ? ` const me = await findUser(locals.user.email)
3877
- return { user: locals.user, twoFactor: me?.twoFactor ?? false }` : ' return { user: locals.user }'}
3878
- }
3879
-
3880
- export const actions: Actions = {
3881
- default: async ({ request, locals }) => {
3882
- if (!locals.user) throw redirect(302, '/login')
3883
- const form = await request.formData()
3884
- const current = String(form.get('current') ?? '')
3885
- const next = String(form.get('next') ?? '')
3886
- if (next.length < 8) return fail(400, { error: 'New password must be at least 8 characters.' })
3887
- const user = await findUser(locals.user.email)
3888
- if (!user || !(await verifyPassword(current, user.passwordHash))) return fail(401, { error: 'Current password is incorrect.' })
3889
- await updatePassword(user.email, next)
3890
- return { ok: true }
3891
- },${twoFactor ? `
3892
- tfa: async ({ request, locals }) => {
3893
- if (!locals.user) throw redirect(302, '/login')
3894
- const on = String((await request.formData()).get('on') ?? '') === 'true'
3895
- await setTwoFactor(locals.user.email, on)
3896
- return { tfa: on }
3897
- },` : ''}
3898
- }
3943
+ const accountServerTs = `import { fail, redirect } from '@sveltejs/kit'
3944
+ import type { Actions, PageServerLoad } from './$types'
3945
+ import { findUser, updatePassword${twoFactor ? ', setTwoFactor' : ''} } from '$lib/server/users'
3946
+ import { verifyPassword } from '../lib/server/auth'
3947
+
3948
+ export const load: PageServerLoad = async ({ locals }) => {
3949
+ if (!locals.user) throw redirect(302, '/login')
3950
+ ${twoFactor ? ` const me = await findUser(locals.user.email)
3951
+ return { user: locals.user, twoFactor: me?.twoFactor ?? false }` : ' return { user: locals.user }'}
3952
+ }
3953
+
3954
+ export const actions: Actions = {
3955
+ default: async ({ request, locals }) => {
3956
+ if (!locals.user) throw redirect(302, '/login')
3957
+ const form = await request.formData()
3958
+ const current = String(form.get('current') ?? '')
3959
+ const next = String(form.get('next') ?? '')
3960
+ if (next.length < 8) return fail(400, { error: 'New password must be at least 8 characters.' })
3961
+ const user = await findUser(locals.user.email)
3962
+ if (!user || !(await verifyPassword(current, user.passwordHash))) return fail(401, { error: 'Current password is incorrect.' })
3963
+ await updatePassword(user.email, next)
3964
+ return { ok: true }
3965
+ },${twoFactor ? `
3966
+ tfa: async ({ request, locals }) => {
3967
+ if (!locals.user) throw redirect(302, '/login')
3968
+ const on = String((await request.formData()).get('on') ?? '') === 'true'
3969
+ await setTwoFactor(locals.user.email, on)
3970
+ return { tfa: on }
3971
+ },` : ''}
3972
+ }
3899
3973
  `;
3900
- const accountPage = `<script lang="ts">
3901
- import { enhance } from '$app/forms'
3902
- let { data, form } = $props()
3903
- </script>
3904
-
3905
- <h1 class="st__title">Account</h1>
3906
- <div class="acct">
3907
- <p class="st__sub">Signed in as <strong>{data.user.name}</strong> ({data.user.email}).</p>
3908
- <form method="POST" use:enhance class="acct__form">
3909
- <h2 class="acct__h">Change password</h2>
3910
- {#if form?.ok}<p class="acct__ok">Password updated.</p>{/if}
3911
- {#if form?.error}<p class="acct__err" role="alert">{form.error}</p>{/if}
3912
- <label class="acct__field"><span>Current password</span><input name="current" type="password" autocomplete="current-password" required /></label>
3913
- <label class="acct__field"><span>New password</span><input name="next" type="password" autocomplete="new-password" minlength="8" required /></label>
3914
- <button class="acct__btn" type="submit">Update password</button>
3915
- </form>${twoFactor ? `
3916
- <form method="POST" action="?/tfa" use:enhance class="acct__form">
3917
- <h2 class="acct__h">Two-factor authentication</h2>
3918
- <p class="st-hint">{data.twoFactor ? 'Enabled - a code is emailed to you at sign-in.' : 'Add an emailed one-time code at sign-in.'}</p>
3919
- <input type="hidden" name="on" value={data.twoFactor ? 'false' : 'true'} />
3920
- <button class="acct__btn" type="submit">{data.twoFactor ? 'Disable' : 'Enable'} two-factor</button>
3921
- </form>` : ''}
3922
- </div>
3923
-
3924
- <style>
3925
- .acct { max-width: 420px; margin-top: 12px; }
3926
- .acct__form { display: flex; flex-direction: column; gap: 10px; margin-top: 16px; padding: 18px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; background: var(--sg-bg, #fff); }
3927
- .acct__h { margin: 0; font-size: 15px; }
3928
- .acct__field { display: flex; flex-direction: column; gap: 4px; font-size: 12.5px; font-weight: 600; color: var(--sg-muted, #64748b); }
3929
- .acct__field input { padding: 8px 10px; font: inherit; font-size: 14px; font-weight: 400; border: 1px solid var(--sg-input-border, #e6e8ec); border-radius: 8px; background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); }
3930
- .acct__btn { align-self: start; margin-top: 4px; padding: 8px 16px; font: inherit; font-size: 13.5px; font-weight: 620; color: var(--sg-on-accent, #fff); background: var(--sg-accent, #6366f1); border: none; border-radius: 8px; cursor: pointer; }
3931
- .acct__ok { margin: 0; color: #166534; font-size: 13px; }
3932
- .acct__err { margin: 0; color: var(--sg-danger, #b3261e); font-size: 13px; }
3933
- </style>
3974
+ const accountPage = `<script lang="ts">
3975
+ import { enhance } from '$app/forms'
3976
+ let { data, form } = $props()
3977
+ </script>
3978
+
3979
+ <h1 class="st__title">Account</h1>
3980
+ <div class="acct">
3981
+ <p class="st__sub">Signed in as <strong>{data.user.name}</strong> ({data.user.email}).</p>
3982
+ <form method="POST" use:enhance class="acct__form">
3983
+ <h2 class="acct__h">Change password</h2>
3984
+ {#if form?.ok}<p class="acct__ok">Password updated.</p>{/if}
3985
+ {#if form?.error}<p class="acct__err" role="alert">{form.error}</p>{/if}
3986
+ <label class="acct__field"><span>Current password</span><input name="current" type="password" autocomplete="current-password" required /></label>
3987
+ <label class="acct__field"><span>New password</span><input name="next" type="password" autocomplete="new-password" minlength="8" required /></label>
3988
+ <button class="acct__btn" type="submit">Update password</button>
3989
+ </form>${twoFactor ? `
3990
+ <form method="POST" action="?/tfa" use:enhance class="acct__form">
3991
+ <h2 class="acct__h">Two-factor authentication</h2>
3992
+ <p class="st-hint">{data.twoFactor ? 'Enabled - a code is emailed to you at sign-in.' : 'Add an emailed one-time code at sign-in.'}</p>
3993
+ <input type="hidden" name="on" value={data.twoFactor ? 'false' : 'true'} />
3994
+ <button class="acct__btn" type="submit">{data.twoFactor ? 'Disable' : 'Enable'} two-factor</button>
3995
+ </form>` : ''}
3996
+ </div>
3997
+
3998
+ <style>
3999
+ .acct { max-width: 420px; margin-top: 12px; }
4000
+ .acct__form { display: flex; flex-direction: column; gap: 10px; margin-top: 16px; padding: 18px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; background: var(--sg-bg, #fff); }
4001
+ .acct__h { margin: 0; font-size: 15px; }
4002
+ .acct__field { display: flex; flex-direction: column; gap: 4px; font-size: 12.5px; font-weight: 600; color: var(--sg-muted, #64748b); }
4003
+ .acct__field input { padding: 8px 10px; font: inherit; font-size: 14px; font-weight: 400; border: 1px solid var(--sg-input-border, #e6e8ec); border-radius: 8px; background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); }
4004
+ .acct__btn { align-self: start; margin-top: 4px; padding: 8px 16px; font: inherit; font-size: 13.5px; font-weight: 620; color: var(--sg-on-accent, #fff); background: var(--sg-accent, #6366f1); border: none; border-radius: 8px; cursor: pointer; }
4005
+ .acct__ok { margin: 0; color: #166534; font-size: 13px; }
4006
+ .acct__err { margin: 0; color: var(--sg-danger, #b3261e); font-size: 13px; }
4007
+ </style>
3934
4008
  `;
3935
4009
  // --- self-service register + password recovery (register flag) ---
3936
- const registerServerTs = `import { fail, redirect } from '@sveltejs/kit'
3937
- import type { Actions, PageServerLoad } from './$types'
3938
- import { createUser } from '../lib/server/users'
3939
- import { SESSION_COOKIE, SESSION_MAX_AGE, signSession } from '../lib/server/auth'
3940
-
3941
- export const load: PageServerLoad = async ({ locals }) => {
3942
- if (locals.user) throw redirect(302, '/')
3943
- return {}
3944
- }
3945
-
3946
- export const actions: Actions = {
3947
- default: async ({ request, cookies, url }) => {
3948
- const form = await request.formData()
3949
- const email = String(form.get('email') ?? '')
3950
- const name = String(form.get('name') ?? '')
3951
- const password = String(form.get('password') ?? '')
3952
- if (!email.trim() || !name.trim() || password.length < 8) return fail(400, { email, name, error: 'Enter a name, email, and a password of at least 8 characters.' })
3953
- const user = await createUser({ email, name, password, role: ${JSON.stringify(registerRole)} })
3954
- if (!user) return fail(409, { email, name, error: 'That email is already registered.' })
3955
- const token = await signSession({ email: user.email, name: user.name, role: user.role })
3956
- cookies.set(SESSION_COOKIE, token, { path: '/', httpOnly: true, sameSite: 'lax', secure: url.hostname !== 'localhost' && url.hostname !== '127.0.0.1', maxAge: SESSION_MAX_AGE })
3957
- throw redirect(302, '/')
3958
- },
3959
- }
4010
+ const registerServerTs = `import { fail, redirect } from '@sveltejs/kit'
4011
+ import type { Actions, PageServerLoad } from './$types'
4012
+ import { createUser } from '../lib/server/users'
4013
+ import { SESSION_COOKIE, SESSION_MAX_AGE, signSession } from '../lib/server/auth'
4014
+
4015
+ export const load: PageServerLoad = async ({ locals }) => {
4016
+ if (locals.user) throw redirect(302, '/')
4017
+ return {}
4018
+ }
4019
+
4020
+ export const actions: Actions = {
4021
+ default: async ({ request, cookies, url }) => {
4022
+ const form = await request.formData()
4023
+ const email = String(form.get('email') ?? '')
4024
+ const name = String(form.get('name') ?? '')
4025
+ const password = String(form.get('password') ?? '')
4026
+ if (!email.trim() || !name.trim() || password.length < 8) return fail(400, { email, name, error: 'Enter a name, email, and a password of at least 8 characters.' })
4027
+ const user = await createUser({ email, name, password, role: ${JSON.stringify(registerRole)} })
4028
+ if (!user) return fail(409, { email, name, error: 'That email is already registered.' })
4029
+ const token = await signSession({ email: user.email, name: user.name, role: user.role })
4030
+ cookies.set(SESSION_COOKIE, token, { path: '/', httpOnly: true, sameSite: 'lax', secure: url.hostname !== 'localhost' && url.hostname !== '127.0.0.1', maxAge: SESSION_MAX_AGE })
4031
+ throw redirect(302, '/')
4032
+ },
4033
+ }
3960
4034
  `;
3961
- const registerPage = `<script lang="ts">
3962
- import { enhance } from '$app/forms'
3963
- let { form } = $props()
3964
- </script>
3965
-
3966
- <div class="auth">
3967
- <form method="POST" use:enhance class="auth__card">
3968
- <h1 class="auth__title">Create account</h1>
3969
- <p class="auth__sub">${jsStrHtml(project.title || '')}</p>
3970
- {#if form?.error}<p class="auth__err" role="alert">{form.error}</p>{/if}
3971
- <label class="auth__field"><span>Name</span><input name="name" autocomplete="name" value={form?.name ?? ''} required /></label>
3972
- <label class="auth__field"><span>Email</span><input name="email" type="email" autocomplete="username" value={form?.email ?? ''} required /></label>
3973
- <label class="auth__field"><span>Password</span><input name="password" type="password" autocomplete="new-password" minlength="8" required /></label>
3974
- <button class="auth__btn" type="submit">Create account</button>
3975
- <p class="auth__links"><a href="/login">Have an account? Sign in</a></p>
3976
- </form>
3977
- </div>
3978
-
3979
- ${authStyle}
4035
+ const registerPage = `<script lang="ts">
4036
+ import { enhance } from '$app/forms'
4037
+ let { form } = $props()
4038
+ </script>
4039
+
4040
+ <div class="auth">
4041
+ <form method="POST" use:enhance class="auth__card">
4042
+ <h1 class="auth__title">Create account</h1>
4043
+ <p class="auth__sub">${jsStrHtml(project.title || '')}</p>
4044
+ {#if form?.error}<p class="auth__err" role="alert">{form.error}</p>{/if}
4045
+ <label class="auth__field"><span>Name</span><input name="name" autocomplete="name" value={form?.name ?? ''} required /></label>
4046
+ <label class="auth__field"><span>Email</span><input name="email" type="email" autocomplete="username" value={form?.email ?? ''} required /></label>
4047
+ <label class="auth__field"><span>Password</span><input name="password" type="password" autocomplete="new-password" minlength="8" required /></label>
4048
+ <button class="auth__btn" type="submit">Create account</button>
4049
+ <p class="auth__links"><a href="/login">Have an account? Sign in</a></p>
4050
+ </form>
4051
+ </div>
4052
+
4053
+ ${authStyle}
3980
4054
  `;
3981
4055
  const forgotSend = emailReal
3982
- ? `if (user) {
3983
- const link = url.origin + '/reset-password?token=' + encodeURIComponent(await signReset(user.email))
3984
- await sendEmail(user.email, 'Reset your password', '<p>Reset your password with the link below (expires in 30 minutes):</p><p><a href="' + link + '">' + link + '</a></p>')
4056
+ ? `if (user) {
4057
+ const link = url.origin + '/reset-password?token=' + encodeURIComponent(await signReset(user.email))
4058
+ await sendEmail(user.email, 'Reset your password', '<p>Reset your password with the link below (expires in 30 minutes):</p><p><a href="' + link + '">' + link + '</a></p>')
3985
4059
  }`
3986
4060
  : `if (user) await sendResetEmail(user.email, url.origin + '/reset-password?token=' + encodeURIComponent(await signReset(user.email)))`;
3987
- const forgotServerTs = `import type { Actions } from './$types'
3988
- import { findUser } from '../lib/server/users'
3989
- import { signReset${emailReal ? '' : ', sendResetEmail'} } from '$lib/server/auth'${emailReal ? "\nimport { sendEmail } from '$lib/server/email'" : ''}
3990
-
3991
- export const actions: Actions = {
3992
- default: async ({ request, url }) => {
3993
- const email = String((await request.formData()).get('email') ?? '').trim().toLowerCase()
3994
- const user = await findUser(email)
3995
- // Respond identically whether or not the account exists (no account enumeration).
3996
- ${forgotSend}
3997
- return { sent: true }
3998
- },
3999
- }
4061
+ const forgotServerTs = `import type { Actions } from './$types'
4062
+ import { findUser } from '../lib/server/users'
4063
+ import { signReset${emailReal ? '' : ', sendResetEmail'} } from '$lib/server/auth'${emailReal ? "\nimport { sendEmail } from '$lib/server/email'" : ''}
4064
+
4065
+ export const actions: Actions = {
4066
+ default: async ({ request, url }) => {
4067
+ const email = String((await request.formData()).get('email') ?? '').trim().toLowerCase()
4068
+ const user = await findUser(email)
4069
+ // Respond identically whether or not the account exists (no account enumeration).
4070
+ ${forgotSend}
4071
+ return { sent: true }
4072
+ },
4073
+ }
4000
4074
  `;
4001
- const forgotPage = `<script lang="ts">
4002
- import { enhance } from '$app/forms'
4003
- let { form } = $props()
4004
- </script>
4005
-
4006
- <div class="auth">
4007
- <form method="POST" use:enhance class="auth__card">
4008
- <h1 class="auth__title">Reset password</h1>
4009
- <p class="auth__sub">We'll email you a reset link.</p>
4010
- {#if form?.sent}<p class="auth__ok">If that account exists, a reset link is on its way. (Dev: the link is logged to the server console.)</p>{/if}
4011
- <label class="auth__field"><span>Email</span><input name="email" type="email" autocomplete="username" required /></label>
4012
- <button class="auth__btn" type="submit">Send reset link</button>
4013
- <p class="auth__links"><a href="/login">Back to sign in</a></p>
4014
- </form>
4015
- </div>
4016
-
4017
- ${authStyle}
4075
+ const forgotPage = `<script lang="ts">
4076
+ import { enhance } from '$app/forms'
4077
+ let { form } = $props()
4078
+ </script>
4079
+
4080
+ <div class="auth">
4081
+ <form method="POST" use:enhance class="auth__card">
4082
+ <h1 class="auth__title">Reset password</h1>
4083
+ <p class="auth__sub">We'll email you a reset link.</p>
4084
+ {#if form?.sent}<p class="auth__ok">If that account exists, a reset link is on its way. (Dev: the link is logged to the server console.)</p>{/if}
4085
+ <label class="auth__field"><span>Email</span><input name="email" type="email" autocomplete="username" required /></label>
4086
+ <button class="auth__btn" type="submit">Send reset link</button>
4087
+ <p class="auth__links"><a href="/login">Back to sign in</a></p>
4088
+ </form>
4089
+ </div>
4090
+
4091
+ ${authStyle}
4018
4092
  `;
4019
- const resetServerTs = `import { fail, redirect } from '@sveltejs/kit'
4020
- import type { Actions, PageServerLoad } from './$types'
4021
- import { readReset } from '../lib/server/auth'
4022
- import { updatePassword } from '../lib/server/users'
4023
-
4024
- export const load: PageServerLoad = async ({ url }) => {
4025
- return { valid: !!(await readReset(url.searchParams.get('token') ?? undefined)) }
4026
- }
4027
-
4028
- export const actions: Actions = {
4029
- default: async ({ request, url }) => {
4030
- const email = await readReset(url.searchParams.get('token') ?? undefined)
4031
- if (!email) return fail(400, { error: 'This reset link is invalid or has expired.' })
4032
- const next = String((await request.formData()).get('password') ?? '')
4033
- if (next.length < 8) return fail(400, { error: 'Password must be at least 8 characters.' })
4034
- await updatePassword(email, next)
4035
- throw redirect(302, '/login')
4036
- },
4037
- }
4093
+ const resetServerTs = `import { fail, redirect } from '@sveltejs/kit'
4094
+ import type { Actions, PageServerLoad } from './$types'
4095
+ import { readReset } from '../lib/server/auth'
4096
+ import { updatePassword } from '../lib/server/users'
4097
+
4098
+ export const load: PageServerLoad = async ({ url }) => {
4099
+ return { valid: !!(await readReset(url.searchParams.get('token') ?? undefined)) }
4100
+ }
4101
+
4102
+ export const actions: Actions = {
4103
+ default: async ({ request, url }) => {
4104
+ const email = await readReset(url.searchParams.get('token') ?? undefined)
4105
+ if (!email) return fail(400, { error: 'This reset link is invalid or has expired.' })
4106
+ const next = String((await request.formData()).get('password') ?? '')
4107
+ if (next.length < 8) return fail(400, { error: 'Password must be at least 8 characters.' })
4108
+ await updatePassword(email, next)
4109
+ throw redirect(302, '/login')
4110
+ },
4111
+ }
4038
4112
  `;
4039
- const resetPage = `<script lang="ts">
4040
- import { enhance } from '$app/forms'
4041
- let { data, form } = $props()
4042
- </script>
4043
-
4044
- <div class="auth">
4045
- <form method="POST" use:enhance class="auth__card">
4046
- <h1 class="auth__title">Set a new password</h1>
4047
- {#if !data.valid}
4048
- <p class="auth__err">This reset link is invalid or has expired. <a href="/forgot-password">Request a new one</a>.</p>
4049
- {:else}
4050
- {#if form?.error}<p class="auth__err" role="alert">{form.error}</p>{/if}
4051
- <label class="auth__field"><span>New password</span><input name="password" type="password" autocomplete="new-password" minlength="8" required /></label>
4052
- <button class="auth__btn" type="submit">Update password</button>
4053
- {/if}
4054
- <p class="auth__links"><a href="/login">Back to sign in</a></p>
4055
- </form>
4056
- </div>
4057
-
4058
- ${authStyle}
4113
+ const resetPage = `<script lang="ts">
4114
+ import { enhance } from '$app/forms'
4115
+ let { data, form } = $props()
4116
+ </script>
4117
+
4118
+ <div class="auth">
4119
+ <form method="POST" use:enhance class="auth__card">
4120
+ <h1 class="auth__title">Set a new password</h1>
4121
+ {#if !data.valid}
4122
+ <p class="auth__err">This reset link is invalid or has expired. <a href="/forgot-password">Request a new one</a>.</p>
4123
+ {:else}
4124
+ {#if form?.error}<p class="auth__err" role="alert">{form.error}</p>{/if}
4125
+ <label class="auth__field"><span>New password</span><input name="password" type="password" autocomplete="new-password" minlength="8" required /></label>
4126
+ <button class="auth__btn" type="submit">Update password</button>
4127
+ {/if}
4128
+ <p class="auth__links"><a href="/login">Back to sign in</a></p>
4129
+ </form>
4130
+ </div>
4131
+
4132
+ ${authStyle}
4059
4133
  `;
4060
4134
  // --- admin user management (userAdmin flag: DB-backed + RBAC) ---
4061
- const usersServerTs = `import { fail, redirect } from '@sveltejs/kit'
4062
- import type { Actions, PageServerLoad, RequestEvent } from './$types'
4063
- import { getServerRole, canScreen, ROLES } from '../lib/access'
4064
- import { listUsers, createUser, setUserRole, deleteUser } from '../lib/server/users'
4065
-
4066
- // Only a full-access role (screens: '*') may manage users.
4067
- function guard(event: RequestEvent): void {
4068
- if (!canScreen(getServerRole(event), '__users__')) throw redirect(302, '/')
4069
- }
4070
-
4071
- export const load: PageServerLoad = async (event) => {
4072
- guard(event)
4073
- return { users: await listUsers(), roles: ROLES }
4074
- }
4075
-
4076
- export const actions: Actions = {
4077
- create: async (event) => {
4078
- guard(event)
4079
- const form = await event.request.formData()
4080
- const email = String(form.get('email') ?? '')
4081
- const name = String(form.get('name') ?? '')
4082
- const password = String(form.get('password') ?? '')
4083
- const role = String(form.get('role') ?? ROLES[0])
4084
- if (!email.trim() || !name.trim() || password.length < 8) return fail(400, { error: 'Name, email, and an 8+ character password are required.' })
4085
- if (!(await createUser({ email, name, password, role }))) return fail(409, { error: 'That email already exists.' })
4086
- return { ok: 'created' }
4087
- },
4088
- role: async (event) => {
4089
- guard(event)
4090
- const form = await event.request.formData()
4091
- await setUserRole(String(form.get('email') ?? ''), String(form.get('role') ?? ''))
4092
- return { ok: 'updated' }
4093
- },
4094
- remove: async (event) => {
4095
- guard(event)
4096
- await deleteUser(String((await event.request.formData()).get('email') ?? ''))
4097
- return { ok: 'removed' }
4098
- },
4099
- }
4135
+ const usersServerTs = `import { fail, redirect } from '@sveltejs/kit'
4136
+ import type { Actions, PageServerLoad, RequestEvent } from './$types'
4137
+ import { getServerRole, canScreen, ROLES } from '../lib/access'
4138
+ import { listUsers, createUser, setUserRole, deleteUser } from '../lib/server/users'
4139
+
4140
+ // Only a full-access role (screens: '*') may manage users.
4141
+ function guard(event: RequestEvent): void {
4142
+ if (!canScreen(getServerRole(event), '__users__')) throw redirect(302, '/')
4143
+ }
4144
+
4145
+ export const load: PageServerLoad = async (event) => {
4146
+ guard(event)
4147
+ return { users: await listUsers(), roles: ROLES }
4148
+ }
4149
+
4150
+ export const actions: Actions = {
4151
+ create: async (event) => {
4152
+ guard(event)
4153
+ const form = await event.request.formData()
4154
+ const email = String(form.get('email') ?? '')
4155
+ const name = String(form.get('name') ?? '')
4156
+ const password = String(form.get('password') ?? '')
4157
+ const role = String(form.get('role') ?? ROLES[0])
4158
+ if (!email.trim() || !name.trim() || password.length < 8) return fail(400, { error: 'Name, email, and an 8+ character password are required.' })
4159
+ if (!(await createUser({ email, name, password, role }))) return fail(409, { error: 'That email already exists.' })
4160
+ return { ok: 'created' }
4161
+ },
4162
+ role: async (event) => {
4163
+ guard(event)
4164
+ const form = await event.request.formData()
4165
+ await setUserRole(String(form.get('email') ?? ''), String(form.get('role') ?? ''))
4166
+ return { ok: 'updated' }
4167
+ },
4168
+ remove: async (event) => {
4169
+ guard(event)
4170
+ await deleteUser(String((await event.request.formData()).get('email') ?? ''))
4171
+ return { ok: 'removed' }
4172
+ },
4173
+ }
4100
4174
  `;
4101
- const usersPage = `<script lang="ts">
4102
- import { enhance } from '$app/forms'
4103
- let { data, form } = $props()
4104
- </script>
4105
-
4106
- <h1 class="st__title">Users</h1>
4107
- {#if form?.error}<p class="usr__msg usr__msg--err" role="alert">{form.error}</p>{/if}
4108
- <div class="usr">
4109
- <table class="usr__table">
4110
- <thead><tr><th>Name</th><th>Email</th><th>Role</th><th></th></tr></thead>
4111
- <tbody>
4112
- {#each data.users as u (u.email)}
4113
- <tr>
4114
- <td>{u.name}</td>
4115
- <td>{u.email}</td>
4116
- <td>
4117
- <form method="POST" action="?/role" use:enhance>
4118
- <input type="hidden" name="email" value={u.email} />
4119
- <select name="role" onchange={(e) => e.currentTarget.form?.requestSubmit()}>
4120
- {#each data.roles as r (r)}<option value={r} selected={r === u.role}>{r}</option>{/each}
4121
- </select>
4122
- </form>
4123
- </td>
4124
- <td>
4125
- <form method="POST" action="?/remove" use:enhance>
4126
- <input type="hidden" name="email" value={u.email} />
4127
- <button class="usr__del" type="submit">Remove</button>
4128
- </form>
4129
- </td>
4130
- </tr>
4131
- {/each}
4132
- </tbody>
4133
- </table>
4134
-
4135
- <form method="POST" action="?/create" use:enhance class="usr__add">
4136
- <h2 class="usr__h">Add user</h2>
4137
- <input name="name" placeholder="Name" required />
4138
- <input name="email" type="email" placeholder="Email" required />
4139
- <input name="password" type="password" placeholder="Password (8+)" minlength="8" required />
4140
- <select name="role">{#each data.roles as r (r)}<option value={r}>{r}</option>{/each}</select>
4141
- <button class="usr__btn" type="submit">Add user</button>
4142
- </form>
4143
- </div>
4144
-
4145
- <style>
4146
- .usr { display: flex; flex-direction: column; gap: 18px; margin-top: 14px; }
4147
- .usr__table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
4148
- .usr__table th { text-align: left; padding: 8px 10px; color: var(--sg-muted, #64748b); font-weight: 600; border-bottom: 1px solid var(--sg-border, #e6e8ec); }
4149
- .usr__table td { padding: 7px 10px; border-bottom: 1px solid var(--sg-border, #f1f5f9); }
4150
- .usr__table select { padding: 5px 8px; font: inherit; font-size: 13px; border: 1px solid var(--sg-input-border, #e6e8ec); border-radius: 7px; background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); }
4151
- .usr__del { padding: 4px 10px; font: inherit; font-size: 12.5px; color: #dc2626; background: none; border: 1px solid color-mix(in srgb, #dc2626 40%, var(--sg-border, #e6e8ec)); border-radius: 7px; cursor: pointer; }
4152
- .usr__add { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding: 16px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; background: var(--sg-bg, #fff); }
4153
- .usr__h { width: 100%; margin: 0 0 4px; font-size: 15px; }
4154
- .usr__add input, .usr__add select { padding: 8px 10px; font: inherit; font-size: 13.5px; border: 1px solid var(--sg-input-border, #e6e8ec); border-radius: 8px; background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); }
4155
- .usr__btn { padding: 8px 16px; font: inherit; font-size: 13.5px; font-weight: 620; color: var(--sg-on-accent, #fff); background: var(--sg-accent, #6366f1); border: none; border-radius: 8px; cursor: pointer; }
4156
- .usr__msg { margin: 0 0 10px; padding: 8px 11px; border-radius: 9px; font-size: 13px; }
4157
- .usr__msg--err { color: var(--sg-danger, #b3261e); background: color-mix(in srgb, var(--sg-danger, #dc2626) 9%, var(--sg-bg, #fff)); border: 1px solid color-mix(in srgb, var(--sg-danger, #dc2626) 35%, var(--sg-border, #e6e8ec)); }
4158
- </style>
4175
+ const usersPage = `<script lang="ts">
4176
+ import { enhance } from '$app/forms'
4177
+ let { data, form } = $props()
4178
+ </script>
4179
+
4180
+ <h1 class="st__title">Users</h1>
4181
+ {#if form?.error}<p class="usr__msg usr__msg--err" role="alert">{form.error}</p>{/if}
4182
+ <div class="usr">
4183
+ <table class="usr__table">
4184
+ <thead><tr><th>Name</th><th>Email</th><th>Role</th><th></th></tr></thead>
4185
+ <tbody>
4186
+ {#each data.users as u (u.email)}
4187
+ <tr>
4188
+ <td>{u.name}</td>
4189
+ <td>{u.email}</td>
4190
+ <td>
4191
+ <form method="POST" action="?/role" use:enhance>
4192
+ <input type="hidden" name="email" value={u.email} />
4193
+ <select name="role" onchange={(e) => e.currentTarget.form?.requestSubmit()}>
4194
+ {#each data.roles as r (r)}<option value={r} selected={r === u.role}>{r}</option>{/each}
4195
+ </select>
4196
+ </form>
4197
+ </td>
4198
+ <td>
4199
+ <form method="POST" action="?/remove" use:enhance>
4200
+ <input type="hidden" name="email" value={u.email} />
4201
+ <button class="usr__del" type="submit">Remove</button>
4202
+ </form>
4203
+ </td>
4204
+ </tr>
4205
+ {/each}
4206
+ </tbody>
4207
+ </table>
4208
+
4209
+ <form method="POST" action="?/create" use:enhance class="usr__add">
4210
+ <h2 class="usr__h">Add user</h2>
4211
+ <input name="name" placeholder="Name" required />
4212
+ <input name="email" type="email" placeholder="Email" required />
4213
+ <input name="password" type="password" placeholder="Password (8+)" minlength="8" required />
4214
+ <select name="role">{#each data.roles as r (r)}<option value={r}>{r}</option>{/each}</select>
4215
+ <button class="usr__btn" type="submit">Add user</button>
4216
+ </form>
4217
+ </div>
4218
+
4219
+ <style>
4220
+ .usr { display: flex; flex-direction: column; gap: 18px; margin-top: 14px; }
4221
+ .usr__table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
4222
+ .usr__table th { text-align: left; padding: 8px 10px; color: var(--sg-muted, #64748b); font-weight: 600; border-bottom: 1px solid var(--sg-border, #e6e8ec); }
4223
+ .usr__table td { padding: 7px 10px; border-bottom: 1px solid var(--sg-border, #f1f5f9); }
4224
+ .usr__table select { padding: 5px 8px; font: inherit; font-size: 13px; border: 1px solid var(--sg-input-border, #e6e8ec); border-radius: 7px; background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); }
4225
+ .usr__del { padding: 4px 10px; font: inherit; font-size: 12.5px; color: #dc2626; background: none; border: 1px solid color-mix(in srgb, #dc2626 40%, var(--sg-border, #e6e8ec)); border-radius: 7px; cursor: pointer; }
4226
+ .usr__add { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding: 16px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; background: var(--sg-bg, #fff); }
4227
+ .usr__h { width: 100%; margin: 0 0 4px; font-size: 15px; }
4228
+ .usr__add input, .usr__add select { padding: 8px 10px; font: inherit; font-size: 13.5px; border: 1px solid var(--sg-input-border, #e6e8ec); border-radius: 8px; background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); }
4229
+ .usr__btn { padding: 8px 16px; font: inherit; font-size: 13.5px; font-weight: 620; color: var(--sg-on-accent, #fff); background: var(--sg-accent, #6366f1); border: none; border-radius: 8px; cursor: pointer; }
4230
+ .usr__msg { margin: 0 0 10px; padding: 8px 11px; border-radius: 9px; font-size: 13px; }
4231
+ .usr__msg--err { color: var(--sg-danger, #b3261e); background: color-mix(in srgb, var(--sg-danger, #dc2626) 9%, var(--sg-bg, #fff)); border: 1px solid color-mix(in srgb, var(--sg-danger, #dc2626) 35%, var(--sg-border, #e6e8ec)); }
4232
+ </style>
4159
4233
  `;
4160
4234
  // --- real email (Resend HTTP / SMTP nodemailer / dev console) ---
4161
- const emailTs = `// Regenerated by SvGrid Studio. Email delivery: Resend (HTTP API, works on edge) or
4162
- // SMTP (nodemailer, Node runtimes). Logs to the console in dev when neither is set.
4163
- import { env } from '$env/dynamic/private'
4164
-
4165
- export async function sendEmail(to: string, subject: string, html: string): Promise<void> {
4166
- const from = env.EMAIL_FROM || 'onboarding@resend.dev'
4167
- if (env.RESEND_API_KEY) {
4168
- const res = await fetch('https://api.resend.com/emails', {
4169
- method: 'POST',
4170
- headers: { authorization: 'Bearer ' + env.RESEND_API_KEY, 'content-type': 'application/json' },
4171
- body: JSON.stringify({ from, to, subject, html }),
4172
- })
4173
- if (!res.ok) throw new Error('Resend send failed: ' + res.status + ' ' + (await res.text()))
4174
- return
4175
- }
4176
- if (env.SMTP_HOST) {
4177
- // Dynamic import so edge builds (Resend, or no email) never bundle nodemailer.
4178
- const nodemailer = (await import('nodemailer')).default
4179
- const transport = nodemailer.createTransport({
4180
- host: env.SMTP_HOST,
4181
- port: Number(env.SMTP_PORT ?? 587),
4182
- secure: env.SMTP_SECURE === 'true',
4183
- auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASS } : undefined,
4184
- })
4185
- await transport.sendMail({ from, to, subject, html })
4186
- return
4187
- }
4188
- console.log('[email] (dev - set RESEND_API_KEY or SMTP_* to send) To: ' + to + ' | ' + subject + '\\n' + html)
4189
- }
4235
+ const emailTs = `// Regenerated by SvGrid Studio. Email delivery: Resend (HTTP API, works on edge) or
4236
+ // SMTP (nodemailer, Node runtimes). Logs to the console in dev when neither is set.
4237
+ import { env } from '$env/dynamic/private'
4238
+
4239
+ export async function sendEmail(to: string, subject: string, html: string): Promise<void> {
4240
+ const from = env.EMAIL_FROM || 'onboarding@resend.dev'
4241
+ if (env.RESEND_API_KEY) {
4242
+ const res = await fetch('https://api.resend.com/emails', {
4243
+ method: 'POST',
4244
+ headers: { authorization: 'Bearer ' + env.RESEND_API_KEY, 'content-type': 'application/json' },
4245
+ body: JSON.stringify({ from, to, subject, html }),
4246
+ })
4247
+ if (!res.ok) throw new Error('Resend send failed: ' + res.status + ' ' + (await res.text()))
4248
+ return
4249
+ }
4250
+ if (env.SMTP_HOST) {
4251
+ // Dynamic import so edge builds (Resend, or no email) never bundle nodemailer.
4252
+ const nodemailer = (await import('nodemailer')).default
4253
+ const transport = nodemailer.createTransport({
4254
+ host: env.SMTP_HOST,
4255
+ port: Number(env.SMTP_PORT ?? 587),
4256
+ secure: env.SMTP_SECURE === 'true',
4257
+ auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASS } : undefined,
4258
+ })
4259
+ await transport.sendMail({ from, to, subject, html })
4260
+ return
4261
+ }
4262
+ console.log('[email] (dev - set RESEND_API_KEY or SMTP_* to send) To: ' + to + ' | ' + subject + '\\n' + html)
4263
+ }
4190
4264
  `;
4191
4265
  // --- email 2FA verify step ---
4192
- const verifyServerTs = `import { fail, redirect } from '@sveltejs/kit'
4193
- import type { Actions, PageServerLoad } from './$types'
4194
- import { SESSION_COOKIE, SESSION_MAX_AGE, TFA_COOKIE, readChallenge, signSession } from '../lib/server/auth'
4195
- import { findUser } from '../lib/server/users'
4196
-
4197
- export const load: PageServerLoad = async ({ cookies }) => {
4198
- if (!cookies.get(TFA_COOKIE)) throw redirect(302, '/login')
4199
- return {}
4200
- }
4201
-
4202
- export const actions: Actions = {
4203
- default: async ({ request, cookies, url }) => {
4204
- const code = String((await request.formData()).get('code') ?? '')
4205
- const email = await readChallenge(cookies.get(TFA_COOKIE), code)
4206
- if (!email) return fail(401, { error: 'That code is invalid or has expired.' })
4207
- const user = await findUser(email)
4208
- if (!user) return fail(401, { error: 'Account not found.' })
4209
- cookies.delete(TFA_COOKIE, { path: '/' })
4210
- cookies.set(SESSION_COOKIE, await signSession({ email: user.email, name: user.name, role: user.role }), {
4211
- path: '/', httpOnly: true, sameSite: 'lax', secure: url.hostname !== 'localhost' && url.hostname !== '127.0.0.1', maxAge: SESSION_MAX_AGE,
4212
- })
4213
- throw redirect(302, url.searchParams.get('redirectTo') || '/')
4214
- },
4215
- }
4266
+ const verifyServerTs = `import { fail, redirect } from '@sveltejs/kit'
4267
+ import type { Actions, PageServerLoad } from './$types'
4268
+ import { SESSION_COOKIE, SESSION_MAX_AGE, TFA_COOKIE, readChallenge, signSession } from '../lib/server/auth'
4269
+ import { findUser } from '../lib/server/users'
4270
+
4271
+ export const load: PageServerLoad = async ({ cookies }) => {
4272
+ if (!cookies.get(TFA_COOKIE)) throw redirect(302, '/login')
4273
+ return {}
4274
+ }
4275
+
4276
+ export const actions: Actions = {
4277
+ default: async ({ request, cookies, url }) => {
4278
+ const code = String((await request.formData()).get('code') ?? '')
4279
+ const email = await readChallenge(cookies.get(TFA_COOKIE), code)
4280
+ if (!email) return fail(401, { error: 'That code is invalid or has expired.' })
4281
+ const user = await findUser(email)
4282
+ if (!user) return fail(401, { error: 'Account not found.' })
4283
+ cookies.delete(TFA_COOKIE, { path: '/' })
4284
+ cookies.set(SESSION_COOKIE, await signSession({ email: user.email, name: user.name, role: user.role }), {
4285
+ path: '/', httpOnly: true, sameSite: 'lax', secure: url.hostname !== 'localhost' && url.hostname !== '127.0.0.1', maxAge: SESSION_MAX_AGE,
4286
+ })
4287
+ throw redirect(302, url.searchParams.get('redirectTo') || '/')
4288
+ },
4289
+ }
4216
4290
  `;
4217
- const verifyPage = `<script lang="ts">
4218
- import { enhance } from '$app/forms'
4219
- let { form } = $props()
4220
- </script>
4221
-
4222
- <div class="auth">
4223
- <form method="POST" use:enhance class="auth__card">
4224
- <h1 class="auth__title">Enter your code</h1>
4225
- <p class="auth__sub">We emailed you a 6-digit verification code.</p>
4226
- {#if form?.error}<p class="auth__err" role="alert">{form.error}</p>{/if}
4227
- <label class="auth__field"><span>Code</span><input name="code" inputmode="numeric" autocomplete="one-time-code" required /></label>
4228
- <button class="auth__btn" type="submit">Verify</button>
4229
- <p class="auth__links"><a href="/login">Back to sign in</a></p>
4230
- </form>
4231
- </div>
4232
-
4233
- ${authStyle}
4291
+ const verifyPage = `<script lang="ts">
4292
+ import { enhance } from '$app/forms'
4293
+ let { form } = $props()
4294
+ </script>
4295
+
4296
+ <div class="auth">
4297
+ <form method="POST" use:enhance class="auth__card">
4298
+ <h1 class="auth__title">Enter your code</h1>
4299
+ <p class="auth__sub">We emailed you a 6-digit verification code.</p>
4300
+ {#if form?.error}<p class="auth__err" role="alert">{form.error}</p>{/if}
4301
+ <label class="auth__field"><span>Code</span><input name="code" inputmode="numeric" autocomplete="one-time-code" required /></label>
4302
+ <button class="auth__btn" type="submit">Verify</button>
4303
+ <p class="auth__links"><a href="/login">Back to sign in</a></p>
4304
+ </form>
4305
+ </div>
4306
+
4307
+ ${authStyle}
4234
4308
  `;
4235
4309
  // --- OAuth / OIDC (dependency-free authorization-code + PKCE) ---
4236
4310
  const providerSet = `new Set<Provider>([${oauthProviders.map((p) => JSON.stringify(p)).join(', ')}])`;
4237
- const oauthTs = `// Regenerated by SvGrid Studio. Dependency-free OAuth 2.0 / OpenID Connect sign-in
4238
- // (authorization-code + PKCE). Set each provider's client id/secret in the environment;
4239
- // register the redirect URI <origin>/auth/<provider>/callback with the provider.
4240
- import { env } from '$env/dynamic/private'
4241
-
4242
- export type Provider = 'github' | 'google' | 'oidc'
4243
- type Endpoints = { authorize: string; token: string; userinfo: string }
4244
- export type ProviderConfig = { clientId: string; clientSecret: string; scope: string; pkce: boolean; endpoints: Endpoints }
4245
-
4246
- function b64url(bytes: ArrayBuffer): string {
4247
- const b = new Uint8Array(bytes)
4248
- let s = ''
4249
- for (const byte of b) s += String.fromCharCode(byte)
4250
- return btoa(s).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')
4251
- }
4252
- export function randomToken(): string { return b64url(crypto.getRandomValues(new Uint8Array(32)).buffer) }
4253
- export async function pkceChallenge(verifier: string): Promise<string> {
4254
- return b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)))
4255
- }
4256
-
4257
- async function endpointsFor(provider: Provider): Promise<Endpoints> {
4258
- if (provider === 'github') return { authorize: 'https://github.com/login/oauth/authorize', token: 'https://github.com/login/oauth/access_token', userinfo: 'https://api.github.com/user' }
4259
- if (provider === 'google') return { authorize: 'https://accounts.google.com/o/oauth2/v2/auth', token: 'https://oauth2.googleapis.com/token', userinfo: 'https://openidconnect.googleapis.com/v1/userinfo' }
4260
- // Generic OIDC (Azure AD / Entra, Okta, Auth0, Keycloak): discover from OIDC_ISSUER.
4261
- const issuer = (env.OIDC_ISSUER ?? '').replace(/\\/$/, '')
4262
- const disc = (await (await fetch(issuer + '/.well-known/openid-configuration')).json()) as { authorization_endpoint: string; token_endpoint: string; userinfo_endpoint: string }
4263
- return { authorize: disc.authorization_endpoint, token: disc.token_endpoint, userinfo: disc.userinfo_endpoint }
4264
- }
4265
-
4266
- export async function providerConfig(provider: Provider): Promise<ProviderConfig> {
4267
- const prefix = provider.toUpperCase()
4268
- return {
4269
- clientId: env[prefix + '_CLIENT_ID'] ?? '',
4270
- clientSecret: env[prefix + '_CLIENT_SECRET'] ?? '',
4271
- scope: provider === 'github' ? 'read:user user:email' : 'openid email profile',
4272
- pkce: provider !== 'github',
4273
- endpoints: await endpointsFor(provider),
4274
- }
4275
- }
4276
-
4277
- export type OAuthProfile = { email: string; name: string }
4278
- export async function fetchProfile(provider: Provider, accessToken: string, userinfo: string): Promise<OAuthProfile | null> {
4279
- const headers = { authorization: 'Bearer ' + accessToken, accept: 'application/json', 'user-agent': 'svgrid-app' }
4280
- const data = (await (await fetch(userinfo, { headers })).json()) as Record<string, unknown>
4281
- let email = typeof data.email === 'string' ? data.email : ''
4282
- const name = typeof data.name === 'string' ? data.name : (typeof data.login === 'string' ? data.login : email)
4283
- if (provider === 'github' && !email) {
4284
- const emails = (await (await fetch('https://api.github.com/user/emails', { headers })).json()) as Array<{ email: string; primary: boolean; verified: boolean }>
4285
- email = (emails.find((e) => e.primary && e.verified) ?? emails.find((e) => e.verified))?.email ?? ''
4286
- }
4287
- return email ? { email: email.toLowerCase(), name: name || email } : null
4288
- }
4311
+ const oauthTs = `// Regenerated by SvGrid Studio. Dependency-free OAuth 2.0 / OpenID Connect sign-in
4312
+ // (authorization-code + PKCE). Set each provider's client id/secret in the environment;
4313
+ // register the redirect URI <origin>/auth/<provider>/callback with the provider.
4314
+ import { env } from '$env/dynamic/private'
4315
+
4316
+ export type Provider = 'github' | 'google' | 'oidc'
4317
+ type Endpoints = { authorize: string; token: string; userinfo: string }
4318
+ export type ProviderConfig = { clientId: string; clientSecret: string; scope: string; pkce: boolean; endpoints: Endpoints }
4319
+
4320
+ function b64url(bytes: ArrayBuffer): string {
4321
+ const b = new Uint8Array(bytes)
4322
+ let s = ''
4323
+ for (const byte of b) s += String.fromCharCode(byte)
4324
+ return btoa(s).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')
4325
+ }
4326
+ export function randomToken(): string { return b64url(crypto.getRandomValues(new Uint8Array(32)).buffer) }
4327
+ export async function pkceChallenge(verifier: string): Promise<string> {
4328
+ return b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)))
4329
+ }
4330
+
4331
+ async function endpointsFor(provider: Provider): Promise<Endpoints> {
4332
+ if (provider === 'github') return { authorize: 'https://github.com/login/oauth/authorize', token: 'https://github.com/login/oauth/access_token', userinfo: 'https://api.github.com/user' }
4333
+ if (provider === 'google') return { authorize: 'https://accounts.google.com/o/oauth2/v2/auth', token: 'https://oauth2.googleapis.com/token', userinfo: 'https://openidconnect.googleapis.com/v1/userinfo' }
4334
+ // Generic OIDC (Azure AD / Entra, Okta, Auth0, Keycloak): discover from OIDC_ISSUER.
4335
+ const issuer = (env.OIDC_ISSUER ?? '').replace(/\\/$/, '')
4336
+ const disc = (await (await fetch(issuer + '/.well-known/openid-configuration')).json()) as { authorization_endpoint: string; token_endpoint: string; userinfo_endpoint: string }
4337
+ return { authorize: disc.authorization_endpoint, token: disc.token_endpoint, userinfo: disc.userinfo_endpoint }
4338
+ }
4339
+
4340
+ export async function providerConfig(provider: Provider): Promise<ProviderConfig> {
4341
+ const prefix = provider.toUpperCase()
4342
+ return {
4343
+ clientId: env[prefix + '_CLIENT_ID'] ?? '',
4344
+ clientSecret: env[prefix + '_CLIENT_SECRET'] ?? '',
4345
+ scope: provider === 'github' ? 'read:user user:email' : 'openid email profile',
4346
+ pkce: provider !== 'github',
4347
+ endpoints: await endpointsFor(provider),
4348
+ }
4349
+ }
4350
+
4351
+ export type OAuthProfile = { email: string; name: string }
4352
+ export async function fetchProfile(provider: Provider, accessToken: string, userinfo: string): Promise<OAuthProfile | null> {
4353
+ const headers = { authorization: 'Bearer ' + accessToken, accept: 'application/json', 'user-agent': 'svgrid-app' }
4354
+ const data = (await (await fetch(userinfo, { headers })).json()) as Record<string, unknown>
4355
+ let email = typeof data.email === 'string' ? data.email : ''
4356
+ const name = typeof data.name === 'string' ? data.name : (typeof data.login === 'string' ? data.login : email)
4357
+ if (provider === 'github' && !email) {
4358
+ const emails = (await (await fetch('https://api.github.com/user/emails', { headers })).json()) as Array<{ email: string; primary: boolean; verified: boolean }>
4359
+ email = (emails.find((e) => e.primary && e.verified) ?? emails.find((e) => e.verified))?.email ?? ''
4360
+ }
4361
+ return email ? { email: email.toLowerCase(), name: name || email } : null
4362
+ }
4289
4363
  `;
4290
- const oauthLoginServerTs = `import { error, redirect } from '@sveltejs/kit'
4291
- import type { RequestHandler } from './$types'
4292
- import { providerConfig, randomToken, pkceChallenge, type Provider } from '../lib/server/oauth'
4293
-
4294
- const PROVIDERS = ${providerSet}
4295
-
4296
- export const GET: RequestHandler = async ({ params, url, cookies }) => {
4297
- const provider = params.provider as Provider
4298
- if (!PROVIDERS.has(provider)) throw error(404)
4299
- const cfg = await providerConfig(provider)
4300
- if (!cfg.clientId) throw error(500, provider + ' sign-in is not configured (set ' + provider.toUpperCase() + '_CLIENT_ID).')
4301
- const secure = url.protocol === 'https:'
4302
- const state = randomToken()
4303
- cookies.set('oauth_state', state, { path: '/', httpOnly: true, sameSite: 'lax', maxAge: 600, secure })
4304
- const q = new URLSearchParams({ client_id: cfg.clientId, redirect_uri: url.origin + '/auth/' + provider + '/callback', response_type: 'code', scope: cfg.scope, state })
4305
- if (cfg.pkce) {
4306
- const verifier = randomToken()
4307
- cookies.set('oauth_verifier', verifier, { path: '/', httpOnly: true, sameSite: 'lax', maxAge: 600, secure })
4308
- q.set('code_challenge', await pkceChallenge(verifier))
4309
- q.set('code_challenge_method', 'S256')
4310
- }
4311
- throw redirect(302, cfg.endpoints.authorize + '?' + q.toString())
4312
- }
4364
+ const oauthLoginServerTs = `import { error, redirect } from '@sveltejs/kit'
4365
+ import type { RequestHandler } from './$types'
4366
+ import { providerConfig, randomToken, pkceChallenge, type Provider } from '../lib/server/oauth'
4367
+
4368
+ const PROVIDERS = ${providerSet}
4369
+
4370
+ export const GET: RequestHandler = async ({ params, url, cookies }) => {
4371
+ const provider = params.provider as Provider
4372
+ if (!PROVIDERS.has(provider)) throw error(404)
4373
+ const cfg = await providerConfig(provider)
4374
+ if (!cfg.clientId) throw error(500, provider + ' sign-in is not configured (set ' + provider.toUpperCase() + '_CLIENT_ID).')
4375
+ const secure = url.protocol === 'https:'
4376
+ const state = randomToken()
4377
+ cookies.set('oauth_state', state, { path: '/', httpOnly: true, sameSite: 'lax', maxAge: 600, secure })
4378
+ const q = new URLSearchParams({ client_id: cfg.clientId, redirect_uri: url.origin + '/auth/' + provider + '/callback', response_type: 'code', scope: cfg.scope, state })
4379
+ if (cfg.pkce) {
4380
+ const verifier = randomToken()
4381
+ cookies.set('oauth_verifier', verifier, { path: '/', httpOnly: true, sameSite: 'lax', maxAge: 600, secure })
4382
+ q.set('code_challenge', await pkceChallenge(verifier))
4383
+ q.set('code_challenge_method', 'S256')
4384
+ }
4385
+ throw redirect(302, cfg.endpoints.authorize + '?' + q.toString())
4386
+ }
4313
4387
  `;
4314
- const oauthCallbackServerTs = `import { error, redirect } from '@sveltejs/kit'
4315
- import type { RequestHandler } from './$types'
4316
- import { providerConfig, fetchProfile, type Provider } from '../lib/server/oauth'
4317
- import { findUser, createUser } from '../lib/server/users'
4318
- import { SESSION_COOKIE, SESSION_MAX_AGE, signSession } from '../lib/server/auth'
4319
-
4320
- const PROVIDERS = ${providerSet}
4321
-
4322
- export const GET: RequestHandler = async ({ params, url, cookies }) => {
4323
- const provider = params.provider as Provider
4324
- if (!PROVIDERS.has(provider)) throw error(404)
4325
- const code = url.searchParams.get('code')
4326
- const state = url.searchParams.get('state')
4327
- if (!code || !state || state !== cookies.get('oauth_state')) throw error(400, 'Invalid OAuth state.')
4328
- const cfg = await providerConfig(provider)
4329
- const body = new URLSearchParams({ client_id: cfg.clientId, client_secret: cfg.clientSecret, code, redirect_uri: url.origin + '/auth/' + provider + '/callback', grant_type: 'authorization_code' })
4330
- const verifier = cookies.get('oauth_verifier')
4331
- if (cfg.pkce && verifier) body.set('code_verifier', verifier)
4332
- const tokenRes = await fetch(cfg.endpoints.token, { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' }, body })
4333
- if (!tokenRes.ok) throw error(502, 'OAuth token exchange failed.')
4334
- const token = (await tokenRes.json()) as { access_token?: string }
4335
- if (!token.access_token) throw error(502, 'No access token returned.')
4336
- const profile = await fetchProfile(provider, token.access_token, cfg.endpoints.userinfo)
4337
- if (!profile) throw error(502, 'Could not read a verified email from the provider.')
4338
- cookies.delete('oauth_state', { path: '/' })
4339
- cookies.delete('oauth_verifier', { path: '/' })
4340
- let user = await findUser(profile.email)
4341
- if (!user) user = await createUser({ email: profile.email, name: profile.name, password: crypto.randomUUID() + crypto.randomUUID(), role: ${JSON.stringify(registerRole)} })
4342
- if (!user) throw error(500, 'Could not create your account.')
4343
- cookies.set(SESSION_COOKIE, await signSession({ email: user.email, name: user.name, role: user.role }), {
4344
- path: '/', httpOnly: true, sameSite: 'lax', secure: url.protocol === 'https:', maxAge: SESSION_MAX_AGE,
4345
- })
4346
- throw redirect(302, '/')
4347
- }
4388
+ const oauthCallbackServerTs = `import { error, redirect } from '@sveltejs/kit'
4389
+ import type { RequestHandler } from './$types'
4390
+ import { providerConfig, fetchProfile, type Provider } from '../lib/server/oauth'
4391
+ import { findUser, createUser } from '../lib/server/users'
4392
+ import { SESSION_COOKIE, SESSION_MAX_AGE, signSession } from '../lib/server/auth'
4393
+
4394
+ const PROVIDERS = ${providerSet}
4395
+
4396
+ export const GET: RequestHandler = async ({ params, url, cookies }) => {
4397
+ const provider = params.provider as Provider
4398
+ if (!PROVIDERS.has(provider)) throw error(404)
4399
+ const code = url.searchParams.get('code')
4400
+ const state = url.searchParams.get('state')
4401
+ if (!code || !state || state !== cookies.get('oauth_state')) throw error(400, 'Invalid OAuth state.')
4402
+ const cfg = await providerConfig(provider)
4403
+ const body = new URLSearchParams({ client_id: cfg.clientId, client_secret: cfg.clientSecret, code, redirect_uri: url.origin + '/auth/' + provider + '/callback', grant_type: 'authorization_code' })
4404
+ const verifier = cookies.get('oauth_verifier')
4405
+ if (cfg.pkce && verifier) body.set('code_verifier', verifier)
4406
+ const tokenRes = await fetch(cfg.endpoints.token, { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' }, body })
4407
+ if (!tokenRes.ok) throw error(502, 'OAuth token exchange failed.')
4408
+ const token = (await tokenRes.json()) as { access_token?: string }
4409
+ if (!token.access_token) throw error(502, 'No access token returned.')
4410
+ const profile = await fetchProfile(provider, token.access_token, cfg.endpoints.userinfo)
4411
+ if (!profile) throw error(502, 'Could not read a verified email from the provider.')
4412
+ cookies.delete('oauth_state', { path: '/' })
4413
+ cookies.delete('oauth_verifier', { path: '/' })
4414
+ let user = await findUser(profile.email)
4415
+ if (!user) user = await createUser({ email: profile.email, name: profile.name, password: crypto.randomUUID() + crypto.randomUUID(), role: ${JSON.stringify(registerRole)} })
4416
+ if (!user) throw error(500, 'Could not create your account.')
4417
+ cookies.set(SESSION_COOKIE, await signSession({ email: user.email, name: user.name, role: user.role }), {
4418
+ path: '/', httpOnly: true, sameSite: 'lax', secure: url.protocol === 'https:', maxAge: SESSION_MAX_AGE,
4419
+ })
4420
+ throw redirect(302, '/')
4421
+ }
4348
4422
  `;
4349
4423
  // NOTE: SESSION_SECRET is added to the shared .env.example by envExample() (which
4350
4424
  // scans the generated source), so it merges with DATABASE_URL etc. - no duplicate file.
@@ -4586,79 +4660,79 @@ function dataLayerFiles(project, sqlEntities, sources, includeUsers = false, use
4586
4660
  a.imports.forEach((i) => colImports.add(i));
4587
4661
  tableBlocks.push(a.block);
4588
4662
  }
4589
- const schemaTs = `// Regenerated by SvGrid Studio. Typed database schema (Drizzle ORM) - the source of
4590
- // truth for migrations: edit here, then run \`npm run db:generate\` && \`npm run db:migrate\`.
4591
- import { ${[...colImports].sort().join(', ')} } from '${cfg.core}'
4592
-
4593
- ${tableBlocks.join('\n\n')}
4663
+ const schemaTs = `// Regenerated by SvGrid Studio. Typed database schema (Drizzle ORM) - the source of
4664
+ // truth for migrations: edit here, then run \`npm run db:generate\` && \`npm run db:migrate\`.
4665
+ import { ${[...colImports].sort().join(', ')} } from '${cfg.core}'
4666
+
4667
+ ${tableBlocks.join('\n\n')}
4594
4668
  `;
4595
- const indexTs = `// Regenerated by SvGrid Studio. The Drizzle client (reads DATABASE_URL).
4596
- ${cfg.client}
4597
- import { env } from '$env/dynamic/private'
4598
- import * as schema from './schema'
4599
-
4600
- ${cfg.setup}
4601
- export { schema }
4669
+ const indexTs = `// Regenerated by SvGrid Studio. The Drizzle client (reads DATABASE_URL).
4670
+ ${cfg.client}
4671
+ import { env } from '$env/dynamic/private'
4672
+ import * as schema from './schema'
4673
+
4674
+ ${cfg.setup}
4675
+ export { schema }
4602
4676
  `;
4603
4677
  const repoFiles = meta.map(({ e, tableVar, pkKey, pkNumber }) => {
4604
4678
  const type = namesFor(e).type;
4605
4679
  const idT = pkNumber ? 'number' : 'string';
4606
4680
  // MySQL has no RETURNING, so create/update re-select the row after writing.
4607
4681
  const crud = cfg.returning
4608
- ? `export const ${tableVar}Repo = {
4609
- list: (): Promise<${type}Row[]> => db.select().from(${tableVar}),
4610
- get: async (id: ${idT}): Promise<${type}Row | undefined> =>
4611
- (await db.select().from(${tableVar}).where(eq(${tableVar}.${pkKey}, id))).at(0),
4612
- create: async (values: ${type}New): Promise<${type}Row> =>
4613
- (await db.insert(${tableVar}).values(values).returning()).at(0)!,
4614
- update: async (id: ${idT}, values: Partial<${type}New>): Promise<${type}Row | undefined> =>
4615
- (await db.update(${tableVar}).set(values).where(eq(${tableVar}.${pkKey}, id)).returning()).at(0),
4616
- remove: async (id: ${idT}): Promise<void> => {
4617
- await db.delete(${tableVar}).where(eq(${tableVar}.${pkKey}, id))
4618
- },
4682
+ ? `export const ${tableVar}Repo = {
4683
+ list: (): Promise<${type}Row[]> => db.select().from(${tableVar}),
4684
+ get: async (id: ${idT}): Promise<${type}Row | undefined> =>
4685
+ (await db.select().from(${tableVar}).where(eq(${tableVar}.${pkKey}, id))).at(0),
4686
+ create: async (values: ${type}New): Promise<${type}Row> =>
4687
+ (await db.insert(${tableVar}).values(values).returning()).at(0)!,
4688
+ update: async (id: ${idT}, values: Partial<${type}New>): Promise<${type}Row | undefined> =>
4689
+ (await db.update(${tableVar}).set(values).where(eq(${tableVar}.${pkKey}, id)).returning()).at(0),
4690
+ remove: async (id: ${idT}): Promise<void> => {
4691
+ await db.delete(${tableVar}).where(eq(${tableVar}.${pkKey}, id))
4692
+ },
4619
4693
  }`
4620
- : `async function getById(id: ${idT}): Promise<${type}Row | undefined> {
4621
- return (await db.select().from(${tableVar}).where(eq(${tableVar}.${pkKey}, id))).at(0)
4622
- }
4623
-
4624
- export const ${tableVar}Repo = {
4625
- list: (): Promise<${type}Row[]> => db.select().from(${tableVar}),
4626
- get: getById,
4627
- // MySQL: no RETURNING - re-select by the new/insertId after the write.
4628
- create: async (values: ${type}New): Promise<${type}Row> => {
4629
- const res = await db.insert(${tableVar}).values(values)
4630
- const id = ((values as Record<string, unknown>).${pkKey} ?? (res as unknown as Array<{ insertId: number }>)[0]?.insertId) as ${idT}
4631
- return (await getById(id))!
4632
- },
4633
- update: async (id: ${idT}, values: Partial<${type}New>): Promise<${type}Row | undefined> => {
4634
- await db.update(${tableVar}).set(values).where(eq(${tableVar}.${pkKey}, id))
4635
- return getById(id)
4636
- },
4637
- remove: async (id: ${idT}): Promise<void> => {
4638
- await db.delete(${tableVar}).where(eq(${tableVar}.${pkKey}, id))
4639
- },
4694
+ : `async function getById(id: ${idT}): Promise<${type}Row | undefined> {
4695
+ return (await db.select().from(${tableVar}).where(eq(${tableVar}.${pkKey}, id))).at(0)
4696
+ }
4697
+
4698
+ export const ${tableVar}Repo = {
4699
+ list: (): Promise<${type}Row[]> => db.select().from(${tableVar}),
4700
+ get: getById,
4701
+ // MySQL: no RETURNING - re-select by the new/insertId after the write.
4702
+ create: async (values: ${type}New): Promise<${type}Row> => {
4703
+ const res = await db.insert(${tableVar}).values(values)
4704
+ const id = ((values as Record<string, unknown>).${pkKey} ?? (res as unknown as Array<{ insertId: number }>)[0]?.insertId) as ${idT}
4705
+ return (await getById(id))!
4706
+ },
4707
+ update: async (id: ${idT}, values: Partial<${type}New>): Promise<${type}Row | undefined> => {
4708
+ await db.update(${tableVar}).set(values).where(eq(${tableVar}.${pkKey}, id))
4709
+ return getById(id)
4710
+ },
4711
+ remove: async (id: ${idT}): Promise<void> => {
4712
+ await db.delete(${tableVar}).where(eq(${tableVar}.${pkKey}, id))
4713
+ },
4640
4714
  }`;
4641
4715
  return {
4642
4716
  path: `src/lib/server/db/${namesFor(e).route}.ts`,
4643
4717
  description: `Typed repository for ${namesFor(e).label} (Drizzle).`,
4644
- contents: `// Regenerated by SvGrid Studio. Typed CRUD over the ${tableVar} table - call from
4645
- // server code, form actions, or your own API routes.
4646
- import { eq } from 'drizzle-orm'
4647
- import { db } from './index'
4648
- import { ${tableVar}, type ${type}Row, type ${type}New } from './schema'
4649
-
4650
- ${crud}
4718
+ contents: `// Regenerated by SvGrid Studio. Typed CRUD over the ${tableVar} table - call from
4719
+ // server code, form actions, or your own API routes.
4720
+ import { eq } from 'drizzle-orm'
4721
+ import { db } from './index'
4722
+ import { ${tableVar}, type ${type}Row, type ${type}New } from './schema'
4723
+
4724
+ ${crud}
4651
4725
  `,
4652
4726
  };
4653
4727
  });
4654
- const configTs = `import { defineConfig } from 'drizzle-kit'
4655
-
4656
- export default defineConfig({
4657
- schema: './src/lib/server/db/schema.ts',
4658
- out: './drizzle',
4659
- dialect: '${cfg.kit}',
4660
- ${cfg.creds},
4661
- })
4728
+ const configTs = `import { defineConfig } from 'drizzle-kit'
4729
+
4730
+ export default defineConfig({
4731
+ schema: './src/lib/server/db/schema.ts',
4732
+ out: './drizzle',
4733
+ dialect: '${cfg.kit}',
4734
+ ${cfg.creds},
4735
+ })
4662
4736
  `;
4663
4737
  // db/seed.ts - inserts the sample rows into EMPTY tables after `db:migrate`, so a
4664
4738
  // freshly-migrated production database doesn't open onto blank grids. Lives outside
@@ -4690,28 +4764,28 @@ export default defineConfig({
4690
4764
  });
4691
4765
  if (!rows.length)
4692
4766
  return null;
4693
- return ` {
4694
- const existing = await db.select().from(schema.${tableVar}).limit(1)
4695
- if (existing.length === 0) {
4696
- await db.insert(schema.${tableVar}).values(${JSON.stringify(rows)} as (typeof schema.${tableVar}.$inferInsert)[])
4697
- console.log('seeded ${e.name} (${rows.length} rows)')
4698
- } else console.log('${e.name} already has rows - skipped')
4767
+ return ` {
4768
+ const existing = await db.select().from(schema.${tableVar}).limit(1)
4769
+ if (existing.length === 0) {
4770
+ await db.insert(schema.${tableVar}).values(${JSON.stringify(rows)} as (typeof schema.${tableVar}.$inferInsert)[])
4771
+ console.log('seeded ${e.name} (${rows.length} rows)')
4772
+ } else console.log('${e.name} already has rows - skipped')
4699
4773
  }`;
4700
4774
  })
4701
4775
  .filter((b) => b !== null);
4702
- const seedTs = `// Regenerated by SvGrid Studio. studio:db-seed
4703
- // Inserts sample rows into EMPTY tables - a table that already has rows is skipped,
4704
- // so this is safe to run repeatedly. Run AFTER migrations: npm run db:seed
4705
- ${cfg.client}
4706
- import * as schema from '../src/lib/server/db/schema'
4707
-
4708
- ${cfg.setup.replaceAll('env.', 'process.env.').replace('export const db', 'const db')}
4709
-
4710
- async function main() {
4711
- ${seedBlocks.join('\n')}
4712
- }
4713
-
4714
- main().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1) })
4776
+ const seedTs = `// Regenerated by SvGrid Studio. studio:db-seed
4777
+ // Inserts sample rows into EMPTY tables - a table that already has rows is skipped,
4778
+ // so this is safe to run repeatedly. Run AFTER migrations: npm run db:seed
4779
+ ${cfg.client}
4780
+ import * as schema from '../src/lib/server/db/schema'
4781
+
4782
+ ${cfg.setup.replaceAll('env.', 'process.env.').replace('export const db', 'const db')}
4783
+
4784
+ async function main() {
4785
+ ${seedBlocks.join('\n')}
4786
+ }
4787
+
4788
+ main().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1) })
4715
4789
  `;
4716
4790
  return [
4717
4791
  { path: 'src/lib/server/db/schema.ts', description: 'Drizzle schema (migration source of truth).', contents: schemaTs },
@@ -4758,106 +4832,106 @@ const SCAFFOLD_STATIC = [
4758
4832
  { path: '.npmrc', description: 'npm config.', contents: `engine-strict=false\n` },
4759
4833
  { path: '.gitignore', description: 'git ignore.', contents: `node_modules\n.svelte-kit\n/build\n.env\n.env.*\n!.env.example\n.DS_Store\n` },
4760
4834
  ];
4761
- const APP_CSS = `:root { --sg-accent: #4f46e5; color-scheme: light dark; }
4762
- * { box-sizing: border-box; }
4763
- html, body { margin: 0; height: 100%; }
4764
- body { font-family: var(--sg-font, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif); color: var(--sg-fg, #0f172a); background: var(--sg-bg, #fff); }
4765
- .st__title { margin: 0; font-size: 22px; font-weight: 720; letter-spacing: -0.015em; }
4766
- .st__sub { margin: 0; font-size: 14px; line-height: 1.6; color: var(--sg-muted, #64748b); max-width: 74ch; }
4767
- .st__sub code { background: var(--sg-header-bg, #f1f5f9); padding: 1px 6px; border-radius: 5px; font-size: 0.9em; }
4768
- .st__toolbar { display: flex; align-items: center; gap: 10px; }
4769
- .st-hint { font-size: 12.5px; color: var(--sg-muted, #94a3b8); }
4770
- .st-error { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 0 0 16px; padding: 11px 14px; border: 1px solid color-mix(in srgb, var(--sg-danger, #dc2626) 40%, var(--sg-border, #e6e8ec)); border-radius: 10px; background: color-mix(in srgb, var(--sg-danger, #dc2626) 8%, var(--sg-bg, #fff)); color: var(--sg-danger, #b3261e); font-size: 13.5px; }
4771
- .st-btn { display: inline-flex; align-items: center; gap: 7px; padding: 8px 14px; font: inherit; font-size: 13.5px; font-weight: 560; line-height: 1; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 10px; background: var(--sg-bg, #fff); color: var(--sg-fg, inherit); cursor: pointer; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); }
4772
- .st-btn:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 5%, var(--sg-bg, #fff)); }
4773
- .st-btn:disabled { opacity: 0.5; cursor: default; box-shadow: none; }
4774
- .st-btn--primary { border-color: transparent; color: #fff; background: linear-gradient(180deg, color-mix(in srgb, var(--sg-accent) 88%, #fff), var(--sg-accent)); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.14), 0 8px 18px -9px color-mix(in srgb, var(--sg-accent) 65%, transparent); }
4775
- .st-btn--primary:hover { filter: brightness(1.06); }
4776
- .home { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; margin-top: 6px; }
4777
- .home__card { display: flex; flex-direction: column; gap: 6px; padding: 18px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 14px; text-decoration: none; color: inherit; background: var(--sg-bg, #fff); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); }
4778
- .home__card:hover { border-color: color-mix(in srgb, var(--sg-accent) 45%, var(--sg-border, #e6e8ec)); }
4779
- .home__card strong { font-size: 15px; }
4780
- .home__card span { font-size: 13px; color: var(--sg-muted, #64748b); line-height: 1.5; }
4781
- .st-filter { display: flex; flex-direction: column; gap: 10px; padding: 14px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; background: var(--sg-bg, #fff); align-self: start; }
4782
- .st-filter__title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--sg-muted, #64748b); }
4783
- .st-filter__row { display: flex; flex-direction: column; gap: 4px; font-size: 12.5px; font-weight: 600; color: var(--sg-muted, #64748b); }
4784
- .st-filter__row select, .st-filter__row input { padding: 7px 9px; font: inherit; font-size: 13px; font-weight: 400; color: var(--sg-fg, inherit); background: var(--sg-input-bg, var(--sg-bg, #fff)); border: 1px solid var(--sg-input-border, var(--sg-border, #e6e8ec)); border-radius: 8px; }
4785
- .st-record-card { border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; background: var(--sg-bg, #fff); padding: 14px; align-self: start; }
4786
- .st-record { margin: 0; display: flex; flex-direction: column; gap: 8px; }
4787
- .st-record__row { display: grid; grid-template-columns: 40% 1fr; gap: 10px; align-items: baseline; border-bottom: 1px solid var(--sg-border, #f1f5f9); padding-bottom: 6px; }
4788
- .st-record__row dt { margin: 0; font-size: 12px; font-weight: 600; color: var(--sg-muted, #64748b); }
4789
- .st-record__row dd { margin: 0; font-size: 13.5px; color: var(--sg-fg, inherit); overflow-wrap: anywhere; }
4790
- .st-screen { display: grid; grid-template-columns: repeat(12, 1fr); gap: 16px; align-items: start; }
4791
- /* Mobile: blocks stack full-width (a span-N block clamps to the single column). */
4792
- @media (max-width: 720px) { .st-screen { grid-template-columns: 1fr; gap: 12px; } }
4793
- /* Stack layout: every block full-width in a single flowing column (mobile-first). */
4794
- .st-stack { display: flex; flex-direction: column; gap: 16px; align-items: stretch; }
4795
- .st-stack > * { min-width: 0; width: 100%; }
4796
- @media (max-width: 720px) { .st-stack { gap: 12px; } }
4797
- /* Free-form canvas: blocks placed on a 12-column x fixed-row grid by cell coords. */
4798
- .st-canvas { display: grid; grid-template-columns: repeat(12, 1fr); grid-auto-rows: ${CANVAS_ROW_PX}px; gap: ${CANVAS_GAP_PX}px; align-items: stretch; }
4799
- .st-canvas__cell { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
4800
- .st-canvas__cell > * { flex: 1; min-height: 0; }
4801
- /* Narrow: an absolute canvas can't reflow, so fall back to a single stacked column. */
4802
- @media (max-width: 720px) {
4803
- .st-canvas { display: flex; flex-direction: column; gap: 12px; }
4804
- .st-canvas__cell { grid-column: auto !important; grid-row: auto !important; min-height: ${CANVAS_ROW_PX * 4}px; }
4805
- }
4806
- /* Docking workspace: the SvDockManager fills a sized region (it owns its own scrolling). */
4807
- .st-dock { height: min(74vh, 900px); min-height: 420px; }
4808
- @media (max-width: 640px) { .st__title { font-size: 19px; } }
4809
- .st-rowactions { display: inline-flex; gap: 6px; }
4810
- .st-rowaction { padding: 3px 9px; font: inherit; font-size: 12px; font-weight: 550; line-height: 1.4; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 7px; background: var(--sg-bg, #fff); color: var(--sg-fg, inherit); cursor: pointer; }
4811
- .st-rowaction:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 5%, var(--sg-bg, #fff)); }
4812
- .st-rowaction--danger { color: #dc2626; border-color: color-mix(in srgb, #dc2626 40%, var(--sg-border, #e6e8ec)); }
4813
- .st-rowaction--danger:hover { background: color-mix(in srgb, #dc2626 8%, var(--sg-bg, #fff)); }
4814
- .st-cell-link { color: var(--sg-accent, #4f46e5); text-decoration: none; }
4815
- .st-cell-link:hover { text-decoration: underline; }
4816
- .st-cell-progress { display: flex; align-items: center; min-width: 80px; width: 100%; }
4817
- .st-grid-toolbar { display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; margin-bottom: 8px; }
4818
- .st-treecell { display: inline-flex; align-items: center; gap: 4px; }
4819
- .st-tree-toggle { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; padding: 0; border: none; background: none; color: var(--sg-muted, #94a3b8); font-size: 11px; cursor: pointer; border-radius: 4px; }
4820
- .st-tree-toggle:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 8%, transparent); color: var(--sg-fg, inherit); }
4821
- .st-tree-spacer { display: inline-block; width: 18px; }
4835
+ const APP_CSS = `:root { --sg-accent: #4f46e5; color-scheme: light dark; }
4836
+ * { box-sizing: border-box; }
4837
+ html, body { margin: 0; height: 100%; }
4838
+ body { font-family: var(--sg-font, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif); color: var(--sg-fg, #0f172a); background: var(--sg-bg, #fff); }
4839
+ .st__title { margin: 0; font-size: 22px; font-weight: 720; letter-spacing: -0.015em; }
4840
+ .st__sub { margin: 0; font-size: 14px; line-height: 1.6; color: var(--sg-muted, #64748b); max-width: 74ch; }
4841
+ .st__sub code { background: var(--sg-header-bg, #f1f5f9); padding: 1px 6px; border-radius: 5px; font-size: 0.9em; }
4842
+ .st__toolbar { display: flex; align-items: center; gap: 10px; }
4843
+ .st-hint { font-size: 12.5px; color: var(--sg-muted, #94a3b8); }
4844
+ .st-error { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 0 0 16px; padding: 11px 14px; border: 1px solid color-mix(in srgb, var(--sg-danger, #dc2626) 40%, var(--sg-border, #e6e8ec)); border-radius: 10px; background: color-mix(in srgb, var(--sg-danger, #dc2626) 8%, var(--sg-bg, #fff)); color: var(--sg-danger, #b3261e); font-size: 13.5px; }
4845
+ .st-btn { display: inline-flex; align-items: center; gap: 7px; padding: 8px 14px; font: inherit; font-size: 13.5px; font-weight: 560; line-height: 1; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 10px; background: var(--sg-bg, #fff); color: var(--sg-fg, inherit); cursor: pointer; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); }
4846
+ .st-btn:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 5%, var(--sg-bg, #fff)); }
4847
+ .st-btn:disabled { opacity: 0.5; cursor: default; box-shadow: none; }
4848
+ .st-btn--primary { border-color: transparent; color: #fff; background: linear-gradient(180deg, color-mix(in srgb, var(--sg-accent) 88%, #fff), var(--sg-accent)); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.14), 0 8px 18px -9px color-mix(in srgb, var(--sg-accent) 65%, transparent); }
4849
+ .st-btn--primary:hover { filter: brightness(1.06); }
4850
+ .home { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; margin-top: 6px; }
4851
+ .home__card { display: flex; flex-direction: column; gap: 6px; padding: 18px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 14px; text-decoration: none; color: inherit; background: var(--sg-bg, #fff); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); }
4852
+ .home__card:hover { border-color: color-mix(in srgb, var(--sg-accent) 45%, var(--sg-border, #e6e8ec)); }
4853
+ .home__card strong { font-size: 15px; }
4854
+ .home__card span { font-size: 13px; color: var(--sg-muted, #64748b); line-height: 1.5; }
4855
+ .st-filter { display: flex; flex-direction: column; gap: 10px; padding: 14px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; background: var(--sg-bg, #fff); align-self: start; }
4856
+ .st-filter__title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--sg-muted, #64748b); }
4857
+ .st-filter__row { display: flex; flex-direction: column; gap: 4px; font-size: 12.5px; font-weight: 600; color: var(--sg-muted, #64748b); }
4858
+ .st-filter__row select, .st-filter__row input { padding: 7px 9px; font: inherit; font-size: 13px; font-weight: 400; color: var(--sg-fg, inherit); background: var(--sg-input-bg, var(--sg-bg, #fff)); border: 1px solid var(--sg-input-border, var(--sg-border, #e6e8ec)); border-radius: 8px; }
4859
+ .st-record-card { border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; background: var(--sg-bg, #fff); padding: 14px; align-self: start; }
4860
+ .st-record { margin: 0; display: flex; flex-direction: column; gap: 8px; }
4861
+ .st-record__row { display: grid; grid-template-columns: 40% 1fr; gap: 10px; align-items: baseline; border-bottom: 1px solid var(--sg-border, #f1f5f9); padding-bottom: 6px; }
4862
+ .st-record__row dt { margin: 0; font-size: 12px; font-weight: 600; color: var(--sg-muted, #64748b); }
4863
+ .st-record__row dd { margin: 0; font-size: 13.5px; color: var(--sg-fg, inherit); overflow-wrap: anywhere; }
4864
+ .st-screen { display: grid; grid-template-columns: repeat(12, 1fr); gap: 16px; align-items: start; }
4865
+ /* Mobile: blocks stack full-width (a span-N block clamps to the single column). */
4866
+ @media (max-width: 720px) { .st-screen { grid-template-columns: 1fr; gap: 12px; } }
4867
+ /* Stack layout: every block full-width in a single flowing column (mobile-first). */
4868
+ .st-stack { display: flex; flex-direction: column; gap: 16px; align-items: stretch; }
4869
+ .st-stack > * { min-width: 0; width: 100%; }
4870
+ @media (max-width: 720px) { .st-stack { gap: 12px; } }
4871
+ /* Free-form canvas: blocks placed on a 12-column x fixed-row grid by cell coords. */
4872
+ .st-canvas { display: grid; grid-template-columns: repeat(12, 1fr); grid-auto-rows: ${CANVAS_ROW_PX}px; gap: ${CANVAS_GAP_PX}px; align-items: stretch; }
4873
+ .st-canvas__cell { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
4874
+ .st-canvas__cell > * { flex: 1; min-height: 0; }
4875
+ /* Narrow: an absolute canvas can't reflow, so fall back to a single stacked column. */
4876
+ @media (max-width: 720px) {
4877
+ .st-canvas { display: flex; flex-direction: column; gap: 12px; }
4878
+ .st-canvas__cell { grid-column: auto !important; grid-row: auto !important; min-height: ${CANVAS_ROW_PX * 4}px; }
4879
+ }
4880
+ /* Docking workspace: the SvDockManager fills a sized region (it owns its own scrolling). */
4881
+ .st-dock { height: min(74vh, 900px); min-height: 420px; }
4882
+ @media (max-width: 640px) { .st__title { font-size: 19px; } }
4883
+ .st-rowactions { display: inline-flex; gap: 6px; }
4884
+ .st-rowaction { padding: 3px 9px; font: inherit; font-size: 12px; font-weight: 550; line-height: 1.4; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 7px; background: var(--sg-bg, #fff); color: var(--sg-fg, inherit); cursor: pointer; }
4885
+ .st-rowaction:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 5%, var(--sg-bg, #fff)); }
4886
+ .st-rowaction--danger { color: #dc2626; border-color: color-mix(in srgb, #dc2626 40%, var(--sg-border, #e6e8ec)); }
4887
+ .st-rowaction--danger:hover { background: color-mix(in srgb, #dc2626 8%, var(--sg-bg, #fff)); }
4888
+ .st-cell-link { color: var(--sg-accent, #4f46e5); text-decoration: none; }
4889
+ .st-cell-link:hover { text-decoration: underline; }
4890
+ .st-cell-progress { display: flex; align-items: center; min-width: 80px; width: 100%; }
4891
+ .st-grid-toolbar { display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; margin-bottom: 8px; }
4892
+ .st-treecell { display: inline-flex; align-items: center; gap: 4px; }
4893
+ .st-tree-toggle { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; padding: 0; border: none; background: none; color: var(--sg-muted, #94a3b8); font-size: 11px; cursor: pointer; border-radius: 4px; }
4894
+ .st-tree-toggle:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 8%, transparent); color: var(--sg-fg, inherit); }
4895
+ .st-tree-spacer { display: inline-block; width: 18px; }
4822
4896
  `;
4823
4897
  // NOTE: the generated app ships without a package-lock.json, so the workflows use
4824
4898
  // `npm install` (not `npm ci`) and omit setup-node's lockfile-dependent `cache: npm`
4825
4899
  // - both hard-fail when no lockfile is present. Commit a lockfile later to speed CI up.
4826
4900
  /** Universal CI: install + build + smoke tests on every push / PR. */
4827
- const CI_WORKFLOW = `name: CI
4828
- on:
4829
- push:
4830
- branches: [main]
4831
- pull_request:
4832
- branches: [main]
4833
- jobs:
4834
- build:
4835
- runs-on: ubuntu-latest
4836
- steps:
4837
- - uses: actions/checkout@v4
4838
- - uses: actions/setup-node@v4
4839
- with:
4840
- node-version: 20
4841
- - run: npm install
4842
- - run: npm run build
4843
- - run: npm test
4901
+ const CI_WORKFLOW = `name: CI
4902
+ on:
4903
+ push:
4904
+ branches: [main]
4905
+ pull_request:
4906
+ branches: [main]
4907
+ jobs:
4908
+ build:
4909
+ runs-on: ubuntu-latest
4910
+ steps:
4911
+ - uses: actions/checkout@v4
4912
+ - uses: actions/setup-node@v4
4913
+ with:
4914
+ node-version: 20
4915
+ - run: npm install
4916
+ - run: npm run build
4917
+ - run: npm test
4844
4918
  `;
4845
4919
  /** Wrap deploy steps in the standard checkout + node + install prelude. */
4846
4920
  function deployWorkflow(steps) {
4847
- return `name: Deploy
4848
- on:
4849
- push:
4850
- branches: [main]
4851
- workflow_dispatch:
4852
- jobs:
4853
- deploy:
4854
- runs-on: ubuntu-latest
4855
- steps:
4856
- - uses: actions/checkout@v4
4857
- - uses: actions/setup-node@v4
4858
- with:
4859
- node-version: 20
4860
- - run: npm install
4921
+ return `name: Deploy
4922
+ on:
4923
+ push:
4924
+ branches: [main]
4925
+ workflow_dispatch:
4926
+ jobs:
4927
+ deploy:
4928
+ runs-on: ubuntu-latest
4929
+ steps:
4930
+ - uses: actions/checkout@v4
4931
+ - uses: actions/setup-node@v4
4932
+ with:
4933
+ node-version: 20
4934
+ - run: npm install
4861
4935
  ${steps}`;
4862
4936
  }
4863
4937
  function deployPlan(project) {
@@ -4874,17 +4948,17 @@ function deployPlan(project) {
4874
4948
  dashboard: 'https://vercel.com/new',
4875
4949
  label: 'Vercel',
4876
4950
  secrets: ['VERCEL_TOKEN', 'VERCEL_ORG_ID', 'VERCEL_PROJECT_ID'],
4877
- deployWorkflow: deployWorkflow(` - name: Deploy to Vercel
4878
- if: \${{ secrets.VERCEL_TOKEN != '' }}
4879
- env:
4880
- VERCEL_TOKEN: \${{ secrets.VERCEL_TOKEN }}
4881
- VERCEL_ORG_ID: \${{ secrets.VERCEL_ORG_ID }}
4882
- VERCEL_PROJECT_ID: \${{ secrets.VERCEL_PROJECT_ID }}
4883
- run: |
4884
- npm i -g vercel
4885
- vercel pull --yes --environment=production --token="$VERCEL_TOKEN"
4886
- vercel build --prod --token="$VERCEL_TOKEN"
4887
- vercel deploy --prebuilt --prod --token="$VERCEL_TOKEN"
4951
+ deployWorkflow: deployWorkflow(` - name: Deploy to Vercel
4952
+ if: \${{ secrets.VERCEL_TOKEN != '' }}
4953
+ env:
4954
+ VERCEL_TOKEN: \${{ secrets.VERCEL_TOKEN }}
4955
+ VERCEL_ORG_ID: \${{ secrets.VERCEL_ORG_ID }}
4956
+ VERCEL_PROJECT_ID: \${{ secrets.VERCEL_PROJECT_ID }}
4957
+ run: |
4958
+ npm i -g vercel
4959
+ vercel pull --yes --environment=production --token="$VERCEL_TOKEN"
4960
+ vercel build --prod --token="$VERCEL_TOKEN"
4961
+ vercel deploy --prebuilt --prod --token="$VERCEL_TOKEN"
4888
4962
  `),
4889
4963
  };
4890
4964
  case 'netlify':
@@ -4897,12 +4971,12 @@ function deployPlan(project) {
4897
4971
  dashboard: 'https://app.netlify.com/start',
4898
4972
  label: 'Netlify',
4899
4973
  secrets: ['NETLIFY_AUTH_TOKEN', 'NETLIFY_SITE_ID'],
4900
- deployWorkflow: deployWorkflow(` - name: Deploy to Netlify
4901
- if: \${{ secrets.NETLIFY_AUTH_TOKEN != '' }}
4902
- env:
4903
- NETLIFY_AUTH_TOKEN: \${{ secrets.NETLIFY_AUTH_TOKEN }}
4904
- NETLIFY_SITE_ID: \${{ secrets.NETLIFY_SITE_ID }}
4905
- run: npx netlify deploy --build --prod
4974
+ deployWorkflow: deployWorkflow(` - name: Deploy to Netlify
4975
+ if: \${{ secrets.NETLIFY_AUTH_TOKEN != '' }}
4976
+ env:
4977
+ NETLIFY_AUTH_TOKEN: \${{ secrets.NETLIFY_AUTH_TOKEN }}
4978
+ NETLIFY_SITE_ID: \${{ secrets.NETLIFY_SITE_ID }}
4979
+ run: npx netlify deploy --build --prod
4906
4980
  `),
4907
4981
  };
4908
4982
  case 'cloudflare':
@@ -4915,13 +4989,13 @@ function deployPlan(project) {
4915
4989
  dashboard: 'https://dash.cloudflare.com/?to=/:account/pages/new',
4916
4990
  label: 'Cloudflare Pages',
4917
4991
  secrets: ['CLOUDFLARE_API_TOKEN', 'CLOUDFLARE_ACCOUNT_ID'],
4918
- deployWorkflow: deployWorkflow(` - run: npm run build
4919
- - name: Deploy to Cloudflare Pages
4920
- if: \${{ secrets.CLOUDFLARE_API_TOKEN != '' }}
4921
- env:
4922
- CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
4923
- CLOUDFLARE_ACCOUNT_ID: \${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
4924
- run: npx wrangler pages deploy .svelte-kit/cloudflare --project-name=${slug}
4992
+ deployWorkflow: deployWorkflow(` - run: npm run build
4993
+ - name: Deploy to Cloudflare Pages
4994
+ if: \${{ secrets.CLOUDFLARE_API_TOKEN != '' }}
4995
+ env:
4996
+ CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
4997
+ CLOUDFLARE_ACCOUNT_ID: \${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
4998
+ run: npx wrangler pages deploy .svelte-kit/cloudflare --project-name=${slug}
4925
4999
  `),
4926
5000
  };
4927
5001
  case 'node':
@@ -5199,40 +5273,40 @@ function smokeTestFile(project) {
5199
5273
  });
5200
5274
  const blocks = project.entities.map((e) => {
5201
5275
  const n = namesFor(e);
5202
- return `describe(${JSON.stringify(n.label)}, () => {
5203
- it('exposes grid columns and form fields', () => {
5204
- expect(schemaToColumns(${n.schemaVar}).length).toBeGreaterThan(0)
5205
- expect(schemaToFormFields(${n.schemaVar}).length).toBeGreaterThan(0)
5206
- })
5207
-
5208
- it('round-trips create -> read -> delete through an in-memory source', async () => {
5209
- const idField = ${n.schemaVar}.idField ?? ${n.schemaVar}.fields.find((f) => f.primaryKey)?.field ?? 'id'
5210
- const source = createInMemoryDataSource<${n.type}>([], ${n.schemaVar})
5211
- await source.createRow({ [idField]: 'smoke-1' } as unknown as Partial<${n.type}>)
5212
- expect((await source.getRows({ startRow: 0, endRow: 100, pageIndex: 0, pageSize: 100, sortModel: [], filterModel: {} })).rowCount).toBe(1)
5213
- await source.deleteRow('smoke-1')
5214
- expect((await source.getRows({ startRow: 0, endRow: 100, pageIndex: 0, pageSize: 100, sortModel: [], filterModel: {} })).rowCount).toBe(0)
5215
- })
5276
+ return `describe(${JSON.stringify(n.label)}, () => {
5277
+ it('exposes grid columns and form fields', () => {
5278
+ expect(schemaToColumns(${n.schemaVar}).length).toBeGreaterThan(0)
5279
+ expect(schemaToFormFields(${n.schemaVar}).length).toBeGreaterThan(0)
5280
+ })
5281
+
5282
+ it('round-trips create -> read -> delete through an in-memory source', async () => {
5283
+ const idField = ${n.schemaVar}.idField ?? ${n.schemaVar}.fields.find((f) => f.primaryKey)?.field ?? 'id'
5284
+ const source = createInMemoryDataSource<${n.type}>([], ${n.schemaVar})
5285
+ await source.createRow({ [idField]: 'smoke-1' } as unknown as Partial<${n.type}>)
5286
+ expect((await source.getRows({ startRow: 0, endRow: 100, pageIndex: 0, pageSize: 100, sortModel: [], filterModel: {} })).rowCount).toBe(1)
5287
+ await source.deleteRow('smoke-1')
5288
+ expect((await source.getRows({ startRow: 0, endRow: 100, pageIndex: 0, pageSize: 100, sortModel: [], filterModel: {} })).rowCount).toBe(0)
5289
+ })
5216
5290
  })`;
5217
5291
  });
5218
- return `// Smoke tests generated by SvGrid Studio. Run with \`npm test\`.
5219
- // They prove every entity's schema still renders (grid columns + form fields)
5220
- // and round-trips through the data-source layer, so a schema edit that would
5221
- // break a screen fails here first. Regenerating the app refreshes this file.
5222
- import { describe, it, expect } from 'vitest'
5223
- import { schemaToColumns, schemaToFormFields, createInMemoryDataSource } from '@svgrid/enterprise'
5224
- import { ${imports.join(', ')} } from './schemas'
5225
-
5226
- ${blocks.join('\n\n')}
5292
+ return `// Smoke tests generated by SvGrid Studio. Run with \`npm test\`.
5293
+ // They prove every entity's schema still renders (grid columns + form fields)
5294
+ // and round-trips through the data-source layer, so a schema edit that would
5295
+ // break a screen fails here first. Regenerating the app refreshes this file.
5296
+ import { describe, it, expect } from 'vitest'
5297
+ import { schemaToColumns, schemaToFormFields, createInMemoryDataSource } from '@svgrid/enterprise'
5298
+ import { ${imports.join(', ')} } from './schemas'
5299
+
5300
+ ${blocks.join('\n\n')}
5227
5301
  `;
5228
5302
  }
5229
- const VITEST_CONFIG = `import { defineConfig } from 'vitest/config'
5230
-
5231
- // Node-only test runner for the generated smoke tests (no Svelte/DOM needed).
5232
- // Kept separate from vite.config so the SvelteKit plugin doesn't load here.
5233
- export default defineConfig({
5234
- test: { environment: 'node', include: ['src/**/*.test.ts'] },
5235
- })
5303
+ const VITEST_CONFIG = `import { defineConfig } from 'vitest/config'
5304
+
5305
+ // Node-only test runner for the generated smoke tests (no Svelte/DOM needed).
5306
+ // Kept separate from vite.config so the SvelteKit plugin doesn't load here.
5307
+ export default defineConfig({
5308
+ test: { environment: 'node', include: ['src/**/*.test.ts'] },
5309
+ })
5236
5310
  `;
5237
5311
  /**
5238
5312
  * Emit the COMPLETE runnable SvelteKit + Vite app: the generated screens/data
@@ -5262,38 +5336,38 @@ function cronScheduleFiles(project, plan) {
5262
5336
  contents: JSON.stringify({ crons }, null, 2) + '\n',
5263
5337
  }];
5264
5338
  }
5265
- const steps = jobs.map((j) => ` - name: ${j.name} (${j.cron})
5266
- if: \${{ github.event_name == 'workflow_dispatch' || github.event.schedule == '${j.cron}' }}
5267
- run: |
5268
- curl -fsS -X POST "$CRON_URL?job=${encodeURIComponent(j.id)}" \\
5269
- -H "authorization: Bearer $CRON_SECRET"
5339
+ const steps = jobs.map((j) => ` - name: ${j.name} (${j.cron})
5340
+ if: \${{ github.event_name == 'workflow_dispatch' || github.event.schedule == '${j.cron}' }}
5341
+ run: |
5342
+ curl -fsS -X POST "$CRON_URL?job=${encodeURIComponent(j.id)}" \\
5343
+ -H "authorization: Bearer $CRON_SECRET"
5270
5344
  `).join('');
5271
5345
  const schedules = [...new Set(jobs.map((j) => j.cron))].map((c) => ` - cron: '${c}'`).join('\n');
5272
5346
  return [{
5273
5347
  path: '.github/workflows/cron.yml',
5274
5348
  description: 'Scheduled job runner: calls /api/cron on the deployed app.',
5275
- contents: `# Scheduled jobs for the generated app. GitHub runs this on the schedules
5276
- # below and it calls the app's /api/cron endpoint.
5277
- #
5278
- # Set two repository secrets before it does anything:
5279
- # CRON_URL https://<your-app>/api/cron
5280
- # CRON_SECRET the same value as the app's CRON_SECRET env var
5281
- # Until then the job short-circuits, so CI stays green.
5282
- name: Scheduled jobs
5283
-
5284
- on:
5285
- schedule:
5286
- ${schedules}
5287
- workflow_dispatch:
5288
-
5289
- jobs:
5290
- cron:
5291
- runs-on: ubuntu-latest
5292
- if: \${{ secrets.CRON_URL != '' && secrets.CRON_SECRET != '' }}
5293
- env:
5294
- CRON_URL: \${{ secrets.CRON_URL }}
5295
- CRON_SECRET: \${{ secrets.CRON_SECRET }}
5296
- steps:
5349
+ contents: `# Scheduled jobs for the generated app. GitHub runs this on the schedules
5350
+ # below and it calls the app's /api/cron endpoint.
5351
+ #
5352
+ # Set two repository secrets before it does anything:
5353
+ # CRON_URL https://<your-app>/api/cron
5354
+ # CRON_SECRET the same value as the app's CRON_SECRET env var
5355
+ # Until then the job short-circuits, so CI stays green.
5356
+ name: Scheduled jobs
5357
+
5358
+ on:
5359
+ schedule:
5360
+ ${schedules}
5361
+ workflow_dispatch:
5362
+
5363
+ jobs:
5364
+ cron:
5365
+ runs-on: ubuntu-latest
5366
+ if: \${{ secrets.CRON_URL != '' && secrets.CRON_SECRET != '' }}
5367
+ env:
5368
+ CRON_URL: \${{ secrets.CRON_URL }}
5369
+ CRON_SECRET: \${{ secrets.CRON_SECRET }}
5370
+ steps:
5297
5371
  ${steps}`,
5298
5372
  }];
5299
5373
  }
@@ -5349,37 +5423,37 @@ export function emitStudioFragment(project) {
5349
5423
  const depList = Object.entries(deps).map(([name, v]) => ` ${name}@${v}`).join('\n');
5350
5424
  const envKeys = envKeysUsed(allSource);
5351
5425
  const libFiles = content.filter((f) => f.path.startsWith('src/lib/')).map((f) => `- \`${f.path}\``);
5352
- const fragmentMd = `# Studio fragment - drop into your SvelteKit app
5353
-
5354
- This folder holds ONLY app content - routes under \`src/routes\` and modules under
5355
- \`src/lib\` (plus \`db/\` if present). It has no package.json, config, or app shell,
5356
- so it drops into your existing SvelteKit app.
5357
-
5358
- ## 1. Copy the files
5359
- Merge \`src/routes/*\` and \`src/lib/*\` into your app's \`src/\`. \`$lib\` is a stock
5360
- SvelteKit alias, so imports resolve with no config. Watch for filename collisions
5361
- with your own \`src/lib\` (e.g. \`schemas.ts\`, \`data.ts\`) - rename if needed.
5362
-
5363
- ## 2. Install dependencies
5364
- \`\`\`bash
5365
- npm install ${Object.keys(deps).filter((d) => d !== '@svgrid/grid' && d !== '@svgrid/enterprise').join(' ') || '@svgrid/grid @svgrid/enterprise'}
5366
- \`\`\`
5367
- Full list this fragment imports:
5368
- \`\`\`
5369
- ${depList}
5370
- \`\`\`
5371
-
5372
- ## 3. Styles
5373
- Import \`src/app.css\` once (e.g. in your root \`+layout.svelte\`) - it carries the
5374
- \`.st-*\` page styles the screens use and the \`--sg-*\` design tokens.
5375
-
5376
- ## 4. What your app must provide
5377
- The full-app nav shell, home redirect, auth guard, RBAC bootstrap, i18n provider,
5378
- and theme toggle live in the bundle's \`+layout.svelte\` - NOT here. Wire nav to the
5379
- generated routes from your own layout.${envKeys.length ? `\n\n## Environment\nSet these where your app reads env:\n${envKeys.map((k) => `- \`${k}\``).join('\n')}` : ''}
5380
-
5381
- ## Files included
5382
- ${libFiles.join('\n')}
5426
+ const fragmentMd = `# Studio fragment - drop into your SvelteKit app
5427
+
5428
+ This folder holds ONLY app content - routes under \`src/routes\` and modules under
5429
+ \`src/lib\` (plus \`db/\` if present). It has no package.json, config, or app shell,
5430
+ so it drops into your existing SvelteKit app.
5431
+
5432
+ ## 1. Copy the files
5433
+ Merge \`src/routes/*\` and \`src/lib/*\` into your app's \`src/\`. \`$lib\` is a stock
5434
+ SvelteKit alias, so imports resolve with no config. Watch for filename collisions
5435
+ with your own \`src/lib\` (e.g. \`schemas.ts\`, \`data.ts\`) - rename if needed.
5436
+
5437
+ ## 2. Install dependencies
5438
+ \`\`\`bash
5439
+ npm install ${Object.keys(deps).filter((d) => d !== '@svgrid/grid' && d !== '@svgrid/enterprise').join(' ') || '@svgrid/grid @svgrid/enterprise'}
5440
+ \`\`\`
5441
+ Full list this fragment imports:
5442
+ \`\`\`
5443
+ ${depList}
5444
+ \`\`\`
5445
+
5446
+ ## 3. Styles
5447
+ Import \`src/app.css\` once (e.g. in your root \`+layout.svelte\`) - it carries the
5448
+ \`.st-*\` page styles the screens use and the \`--sg-*\` design tokens.
5449
+
5450
+ ## 4. What your app must provide
5451
+ The full-app nav shell, home redirect, auth guard, RBAC bootstrap, i18n provider,
5452
+ and theme toggle live in the bundle's \`+layout.svelte\` - NOT here. Wire nav to the
5453
+ generated routes from your own layout.${envKeys.length ? `\n\n## Environment\nSet these where your app reads env:\n${envKeys.map((k) => `- \`${k}\``).join('\n')}` : ''}
5454
+
5455
+ ## Files included
5456
+ ${libFiles.join('\n')}
5383
5457
  `;
5384
5458
  return [
5385
5459
  ...content,