@svgrid/enterprise 2.0.4 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +18 -6
  2. package/dist/cdn/svgrid-enterprise.svelte-external.js +14024 -6835
  3. package/dist/node/studio.js +7888 -2459
  4. package/package.json +9 -4
  5. package/src/SvGridMasterDetail.svelte +24 -3
  6. package/src/SvGridScheduler.svelte +4410 -0
  7. package/src/SvPivotDesigner.svelte +1990 -1045
  8. package/src/SvSchemaChart.svelte +10 -9
  9. package/src/ai.test.ts +522 -522
  10. package/src/ai.ts +202 -2
  11. package/src/index.ts +409 -384
  12. package/src/install.ts +10 -0
  13. package/src/pivot-chart.test.ts +86 -0
  14. package/src/pivot-chart.ts +112 -0
  15. package/src/scheduler.ts +37 -0
  16. package/src/scheduling.test.ts +194 -0
  17. package/src/scheduling.ts +293 -0
  18. package/src/sources/filters.ts +6 -0
  19. package/src/studio/HANDLERS-DESIGN.md +142 -0
  20. package/src/studio/cli.ts +7 -2
  21. package/src/studio/emit-project.test.ts +1447 -13
  22. package/src/studio/emit-project.ts +3995 -1273
  23. package/src/studio/emit-schema.ts +146 -29
  24. package/src/studio/index.ts +320 -195
  25. package/src/studio/project.test.ts +370 -0
  26. package/src/studio/project.ts +1146 -26
  27. package/src/studio/sample-data.ts +4 -1
  28. package/src/studio/samples/ats.ts +2 -2
  29. package/src/studio/samples/clinic.ts +4 -2
  30. package/src/studio/samples/crm.ts +16 -8
  31. package/src/studio/samples/events.ts +4 -2
  32. package/src/studio/samples/fleet.ts +4 -2
  33. package/src/studio/samples/gym.ts +4 -2
  34. package/src/studio/samples/hr.ts +3 -1
  35. package/src/studio/samples/live-data.ts +308 -308
  36. package/src/studio/samples/projects.ts +2 -2
  37. package/src/studio/samples/restaurant.ts +4 -2
  38. package/src/studio/samples/samples.test.ts +13 -5
  39. package/src/studio/samples/shared.ts +346 -305
  40. package/src/studio/samples/support.ts +3 -1
  41. package/src/studio/scaffold.test.ts +15 -1
  42. package/src/studio/scaffold.ts +16 -0
  43. package/src/studio/themes.ts +7 -0
  44. package/src/studio/ui-components.ts +472 -0
  45. package/src/sveltekit/transport.test.ts +26 -0
  46. package/src/sveltekit/transport.ts +50 -5
  47. package/dist/designer/assets/index-Dp44bTid.js +0 -939
  48. package/dist/designer/assets/index-RJp6x8tw.css +0 -1
  49. package/dist/designer/assets/jszip.min-CjMo-QGg.js +0 -2
  50. package/dist/designer/index.html +0 -13
@@ -12,7 +12,31 @@
12
12
  */
13
13
  import { resolveIdField, titleCase, type EntityField, type EntityFieldType, type EntitySchema, type ValidationRuleSpec } from '../schema.js'
14
14
  import type { GeneratedFile } from './scaffold.js'
15
- import type { EntityDataSource, RestSource, ShellConfig, SqlDialectKind } from './project.js'
15
+ import type { EntityDataSource, RestSource, ShellConfig, ShellStyle, SqlDialectKind, EntityTriggers, TriggerEvent } from './project.js'
16
+ import { sanitizeClassName, compileTriggerSteps, TRIGGER_EVENTS } from './project.js'
17
+
18
+ /** Build the createKitHandlers `hooks: {...}` option from an entity's triggers.
19
+ * before-hooks alias the mutable payload as `v`; after-hooks alias the saved row. */
20
+ function triggerHooksOpt(triggers: EntityTriggers | undefined): string | null {
21
+ if (!triggers) return null
22
+ const SHAPE: Record<TriggerEvent, { param: string; alias: string }> = {
23
+ beforeCreate: { param: '{ values }', alias: 'const v = values as Record<string, unknown>' },
24
+ beforeUpdate: { param: '{ patch }', alias: 'const v = patch as Record<string, unknown>' },
25
+ beforeDelete: { param: '{ id }', alias: 'const v = { id } as Record<string, unknown>' },
26
+ afterCreate: { param: '{ row }', alias: 'const v = row as Record<string, unknown>' },
27
+ afterUpdate: { param: '{ row }', alias: 'const v = row as Record<string, unknown>' },
28
+ afterDelete: { param: '{ id }', alias: 'const v = { id } as Record<string, unknown>' },
29
+ }
30
+ const entries: string[] = []
31
+ for (const ev of TRIGGER_EVENTS) {
32
+ const steps = triggers[ev]
33
+ if (!steps?.length) continue
34
+ const { param, alias } = SHAPE[ev]
35
+ const body = compileTriggerSteps(steps).split('\n').map((l) => (l ? ' ' + l : l)).join('\n')
36
+ entries.push(` ${ev}: async (${param}) => {\n ${alias}\n${body}\n }`)
37
+ }
38
+ return entries.length ? `hooks: {\n${entries.join(',\n')},\n }` : null
39
+ }
16
40
  import { generateValue } from './sample-data.js'
17
41
 
18
42
  const pascal = (name: string): string =>
@@ -311,7 +335,7 @@ const SQL_DRIVERS: Record<'postgres' | 'mysql' | 'mssql' | 'sqlite' | 'turso', {
311
335
  * the route imports the shared access policy and rejects unauthorized writes -
312
336
  * server-enforced, so a tampered client can't bypass it. When audit is on, every
313
337
  * successful write is recorded. */
314
- function sqlRouteFile(schema: EntitySchema, table: string, dialect?: SqlDialectKind, feat: { access?: boolean; audit?: boolean } = {}): GeneratedFile {
338
+ function sqlRouteFile(schema: EntitySchema, table: string, dialect?: SqlDialectKind, feat: { access?: boolean; audit?: boolean; screenIds?: string[]; triggers?: EntityTriggers } = {}): GeneratedFile {
315
339
  const n = namesFor(schema)
316
340
  const key = (dialect === 'supabase' ? 'postgres' : (dialect ?? 'postgres')) as 'postgres' | 'mysql' | 'mssql' | 'sqlite' | 'turso'
317
341
  const driver = SQL_DRIVERS[key]
@@ -321,8 +345,11 @@ function sqlRouteFile(schema: EntitySchema, table: string, dialect?: SqlDialectK
321
345
  // Every connected route validates writes against the schema server-side, and
322
346
  // (when enabled) authorizes them by role + records an audit entry.
323
347
  const opts = [`schema: ${n.schemaVar}`, `source`, `validate: true`]
324
- if (feat.access) opts.push(`// Server-enforced RBAC: the caller's role comes from the session (event.locals).\n authorize: ({ action, event }) => authorizeAction(getServerRole(event), action)`)
348
+ if (feat.access) opts.push(`// Server-enforced RBAC: the caller's role comes from the session (event.locals). Reads\n // are allowed only if the role can open one of this entity's own screens.\n authorize: ({ action, event }) => authorizeAction(getServerRole(event), action, ${JSON.stringify(feat.screenIds ?? [])})`)
325
349
  if (feat.audit) opts.push(`// Record every successful write to the audit trail.\n audit: (e) => recordAudit({ entity: ${JSON.stringify(schema.name)}, action: e.action, recordId: e.id, values: e.values as Record<string, unknown> | undefined, actor: String(e.event.locals?.role ?? e.event.locals?.user ?? 'system') })`)
350
+ // Server-enforced business rules: the entity's triggers compiled to lifecycle hooks.
351
+ const hooks = triggerHooksOpt(feat.triggers)
352
+ if (hooks) opts.push(`// Business rules, enforced server-side (a client that skips them still can't write bad data).\n ${hooks}`)
326
353
  const handlers = `export const { POST } = createKitHandlers({\n ${opts.join(',\n ')},\n})`
327
354
  return {
328
355
  path: `src/routes/api/${n.route}/+server.ts`,
@@ -553,20 +580,44 @@ export function entityScreenPage(schema: EntitySchema, route?: string, title?: s
553
580
 
554
581
  export type NavItem = { href: string; label: string; id?: string }
555
582
 
556
- export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: ShellConfig; title?: string; themeVars?: Record<string, string>; dark?: boolean; access?: boolean; i18n?: boolean } = {}): GeneratedFile {
557
- const links = [{ href: '/', label: 'Home' }, ...nav]
583
+ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: ShellConfig; title?: string; themeVars?: Record<string, string>; lightVars?: Record<string, string>; darkVars?: Record<string, string>; dark?: boolean; access?: boolean; auth?: boolean; authRoutes?: string[]; authAccount?: boolean; i18n?: boolean; appClass?: string } = {}): GeneratedFile {
584
+ // Nav is the app's own screens; `/` just redirects to the first one, so no separate
585
+ // "Home" link (it would duplicate the first screen).
586
+ const links = nav
558
587
  const shell = opts.shell ?? {}
559
- const style: 'sidebar' | 'top-nav' = shell.style ?? 'sidebar'
588
+ const style: ShellStyle = shell.style ?? 'sidebar'
589
+ // App-level styling hook: an extra class on the shell root so custom.css can target the whole app.
590
+ const appCls = sanitizeClassName(opts.appClass)
591
+ const appClsAttr = appCls ? ` ${appCls}` : ''
560
592
  const brand = (shell.brand ?? '').trim() || opts.title || 'My Studio App'
561
593
  const footer = shell.footer === undefined ? 'Built with SvGrid Studio' : shell.footer
562
594
  const right = style === 'sidebar' && shell.navPosition === 'right'
563
- // Emit the full theme token bundle (+ color-scheme for dark themes) so the
564
- // generated app matches the look-and-feel picked in the designer.
565
- const vars = { ...(opts.themeVars ?? {}) }
566
- if (opts.accent) vars['--sg-accent'] = opts.accent
567
- const varLines = Object.entries(vars).map(([k, v]) => `${k}: ${v};`).join(' ')
568
- const rootRule = [varLines, opts.dark ? 'color-scheme: dark;' : ''].filter(Boolean).join(' ')
569
- const themeHead = rootRule ? `\n<svelte:head><style>:root { ${rootRule} }</style></svelte:head>\n` : ''
595
+ // Emit the full theme token bundle so the generated app matches the look chosen
596
+ // in the designer. When both light + dark token sets are supplied we ship a
597
+ // built-in light/dark switcher: both sets are scoped by [data-theme] on <html>,
598
+ // and the bare :root falls back to the mode picked in Studio (pre-hydration).
599
+ const defaultMode: 'light' | 'dark' = opts.dark ? 'dark' : 'light'
600
+ const withAccent = (m?: Record<string, string>) => {
601
+ const v = { ...(m ?? {}) }
602
+ if (opts.accent) v['--sg-accent'] = opts.accent
603
+ return v
604
+ }
605
+ const declLines = (m: Record<string, string>) => Object.entries(m).map(([k, v]) => `${k}: ${v};`).join(' ')
606
+ const hasSwitch = !!(opts.lightVars && opts.darkVars)
607
+ let themeHead = ''
608
+ if (hasSwitch) {
609
+ const lv = withAccent(opts.lightVars)
610
+ const dv = withAccent(opts.darkVars)
611
+ const defRule = [declLines(defaultMode === 'dark' ? dv : lv), `color-scheme: ${defaultMode};`].join(' ')
612
+ const lightRule = [declLines(lv), 'color-scheme: light;'].join(' ')
613
+ const darkRule = [declLines(dv), 'color-scheme: dark;'].join(' ')
614
+ themeHead = `\n<svelte:head><style>:root { ${defRule} }\n:root[data-theme="light"] { ${lightRule} }\n:root[data-theme="dark"] { ${darkRule} }</style></svelte:head>\n`
615
+ } else {
616
+ const vars = withAccent(opts.themeVars)
617
+ const varLines = declLines(vars)
618
+ const rootRule = [varLines, opts.dark ? 'color-scheme: dark;' : ''].filter(Boolean).join(' ')
619
+ themeHead = rootRule ? `\n<svelte:head><style>:root { ${rootRule} }</style></svelte:head>\n` : ''
620
+ }
570
621
 
571
622
  // i18n: translate nav labels via `nav.<id>` keys (Home has no id -> literal).
572
623
  const navLabel = opts.i18n ? `{item.id ? $t('nav.' + item.id, item.label) : item.label}` : '{item.label}'
@@ -580,18 +631,28 @@ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: Shel
580
631
  {#each locales as loc (loc)}<option value={loc} selected={loc === $currentLocale}>{loc}</option>{/each}
581
632
  </select>`
582
633
  : ''
634
+ const toolbarOn = shell.toolbar !== false && nav.length > 0
635
+ // Built-in light/dark switcher (a sun/moon button). Lives in the toolbar's tools
636
+ // cluster when the toolbar is on, else trails the nav links. Only rendered when
637
+ // both token sets are available (i.e. from the full Studio project emitter).
638
+ const themeToggleBtn = `<button type="button" class="sv-app__tool sv-app__theme" aria-label="Toggle color theme" title="Toggle light / dark" onclick={toggleTheme}>
639
+ {#if theme === 'dark'}<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>{:else}<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>{/if}
640
+ </button>`
641
+ // When there's no toolbar, the toggle trails the nav in its own inline slot.
642
+ const navToggleSlot = hasSwitch && !toolbarOn ? `\n <div class="sv-app__navtools">${themeToggleBtn}</div>` : ''
583
643
  // Clicking any nav link closes the mobile drawer.
584
644
  const linksMarkup = `<nav class="sv-app__links" onclick={() => (navOpen = false)}>
585
645
  {#each nav as item (item.href)}
586
646
  ${linkGate}
587
647
  {/each}
588
- </nav>${localeSwitcher}`
648
+ </nav>${localeSwitcher}${navToggleSlot}`
589
649
  const footMarkup = footer ? `\n <span class="sv-app__foot">{footer}</span>` : ''
590
650
  const footConst = footer ? `\n const footer = ${JSON.stringify(footer)}` : ''
591
651
  // Sidebar-only: a collapsible sidebar that docks on wide screens and becomes an
592
652
  // off-canvas drawer when collapsed or on tablet/phone (<= 1024px). The collapse
593
- // choice persists; tablet/phone start collapsed.
594
- const sideState = style === 'top-nav' ? '' : `
653
+ // choice persists; tablet/phone start collapsed. top-nav and bottom-nav are
654
+ // always-visible bars with no collapse/drawer state.
655
+ const sideState = style !== 'sidebar' ? '' : `
595
656
  let collapsed = $state(false)
596
657
  let narrow = $state(false)
597
658
  const drawer = $derived(collapsed || narrow)
@@ -629,7 +690,6 @@ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: Shel
629
690
  // App-chrome toolbar (docked at the top of the content area for both layouts):
630
691
  // a functional quick-search over the app's screens + an account cluster. Reads as
631
692
  // a real product header. Opt out with `shell.toolbar === false`.
632
- const toolbarOn = shell.toolbar !== false && nav.length > 0
633
693
  const initials = ((brand.match(/\b[A-Za-z0-9]/g) ?? []).slice(0, 2).join('') || 'A').toUpperCase()
634
694
  const brandSlug = brand.toLowerCase().replace(/[^a-z0-9]+/g, '') || 'app'
635
695
  const resultLabel = opts.i18n ? `{r.id ? $t('nav.' + r.id, r.label) : r.label}` : '{r.label}'
@@ -648,6 +708,7 @@ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: Shel
648
708
  {/if}
649
709
  </div>
650
710
  <div class="sv-app__tools">
711
+ ${hasSwitch ? themeToggleBtn : ''}
651
712
  <div class="sv-app__pop">
652
713
  <button type="button" class="sv-app__tool" aria-label="Notifications" aria-expanded={bellOpen} onclick={() => { bellOpen = !bellOpen; menuOpen = false }}>
653
714
  <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>
@@ -667,11 +728,15 @@ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: Shel
667
728
  <button type="button" class="sv-app__avatar" title={brand} aria-label="Account" aria-expanded={menuOpen} onclick={() => { menuOpen = !menuOpen; bellOpen = false }}>{initials}</button>
668
729
  {#if menuOpen}
669
730
  <div class="sv-app__menu sv-app__menu--acct" role="menu">
670
- <div class="sv-app__acct-head"><span class="sv-app__avatar sv-app__avatar--lg" aria-hidden="true">{initials}</span><div class="sv-app__acct-id"><strong>{brand}</strong><span>{acctEmail}</span></div></div>
731
+ <div class="sv-app__acct-head"><span class="sv-app__avatar sv-app__avatar--lg" aria-hidden="true">{initials}</span><div class="sv-app__acct-id"><strong>${opts.auth ? '{data?.user?.name ?? brand}' : '{brand}'}</strong><span>${opts.auth ? '{data?.user?.email ?? acctEmail}' : '{acctEmail}'}</span></div></div>
671
732
  <a class="sv-app__menu-item" href="/" role="menuitem" onclick={() => (menuOpen = false)}>Dashboard</a>
672
- <button type="button" class="sv-app__menu-item" role="menuitem" onclick={() => (menuOpen = false)}>Profile</button>
673
- <button type="button" class="sv-app__menu-item" role="menuitem" onclick={() => (menuOpen = false)}>Settings</button>
674
- <button type="button" class="sv-app__menu-item sv-app__menu-item--danger" role="menuitem" onclick={() => (menuOpen = false)}>Sign out</button>
733
+ ${opts.authAccount
734
+ ? `<a class="sv-app__menu-item" href="/account" role="menuitem" onclick={() => (menuOpen = false)}>Account</a>`
735
+ : `<button type="button" class="sv-app__menu-item" role="menuitem" onclick={() => (menuOpen = false)}>Profile</button>
736
+ <button type="button" class="sv-app__menu-item" role="menuitem" onclick={() => (menuOpen = false)}>Settings</button>`}
737
+ ${opts.auth
738
+ ? `<form method="POST" action="/logout" class="sv-app__signout"><button type="submit" class="sv-app__menu-item sv-app__menu-item--danger" role="menuitem">Sign out</button></form>`
739
+ : `<button type="button" class="sv-app__menu-item sv-app__menu-item--danger" role="menuitem" onclick={() => (menuOpen = false)}>Sign out</button>`}
675
740
  </div>
676
741
  {/if}
677
742
  </div>
@@ -686,14 +751,22 @@ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: Shel
686
751
  </main>`
687
752
 
688
753
  const body = style === 'top-nav'
689
- ? `<div class="sv-app sv-app--top">
754
+ ? `<div class="sv-app sv-app--top${appClsAttr}">
690
755
  <header class="sv-app__bar">
691
756
  ${brandLink}
692
757
  ${linksMarkup}
693
758
  </header>
694
759
  ${mainMarkup}${footer ? `\n <footer class="sv-app__footbar">{footer}</footer>` : ''}
695
760
  </div>`
696
- : `<div class="sv-app sv-app--side${right ? ' sv-app--right' : ''}" class:is-drawer={drawer} class:is-navopen={navOpen}>
761
+ : style === 'bottom-nav'
762
+ ? `<div class="sv-app sv-app--bottom${appClsAttr}">
763
+ ${mainMarkup}
764
+ <footer class="sv-app__bar sv-app__bar--bottom">
765
+ ${brandLink}
766
+ ${linksMarkup}
767
+ </footer>
768
+ </div>`
769
+ : `<div class="sv-app sv-app--side${right ? ' sv-app--right' : ''}${appClsAttr}" class:is-drawer={drawer} class:is-navopen={navOpen}>
697
770
  ${mobileBar}
698
771
  <aside class="sv-app__side">
699
772
  <div class="sv-app__sidehead">
@@ -717,6 +790,24 @@ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: Shel
717
790
  .sv-app__bar { flex-direction: column; align-items: stretch; gap: 10px; padding: 10px 14px; }
718
791
  .sv-app__links { flex-wrap: nowrap; overflow-x: auto; padding-bottom: 2px; -webkit-overflow-scrolling: touch; }
719
792
  .sv-app__link { white-space: nowrap; }
793
+ }`
794
+ : style === 'bottom-nav'
795
+ ? ` .sv-app--bottom { display: flex; flex-direction: column; min-height: 100vh; }
796
+ .sv-app__bar--bottom {
797
+ position: fixed; left: 0; right: 0; bottom: 0; z-index: 40;
798
+ display: flex; align-items: center; gap: 20px; padding: 12px 22px calc(12px + env(safe-area-inset-bottom, 0px));
799
+ border-top: 1px solid color-mix(in srgb, var(--sg-fg, #0f172a) 16%, var(--sg-border, #e6e8ec));
800
+ background: var(--sg-header-bg, #f8fafc);
801
+ }
802
+ .sv-app__bar--bottom .sv-app__links { display: flex; flex-direction: row; gap: 4px; flex-wrap: wrap; }
803
+ /* Clears the fixed bottom bar so page content never renders underneath it. */
804
+ .sv-app--bottom .sv-app__main { padding-bottom: 70px; }
805
+ /* Mobile: brand drops out so the tab strip stays roomy; ties overflow to a horizontal scroll if there are many screens. */
806
+ @media (max-width: 640px) {
807
+ .sv-app__bar--bottom { gap: 10px; padding: 6px 12px calc(6px + env(safe-area-inset-bottom, 0px)); }
808
+ .sv-app__bar--bottom .sv-app__brand { display: none; }
809
+ .sv-app__bar--bottom .sv-app__links { flex-wrap: nowrap; overflow-x: auto; justify-content: flex-start; -webkit-overflow-scrolling: touch; }
810
+ .sv-app__bar--bottom .sv-app__link { white-space: nowrap; }
720
811
  }`
721
812
  : ` .sv-app--side { display: grid; grid-template-columns: 240px minmax(0, 1fr); min-height: 100vh; }
722
813
  .sv-app--side.sv-app--right { grid-template-columns: minmax(0, 1fr) 240px; }
@@ -750,14 +841,30 @@ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: Shel
750
841
  description: `App shell (${style}): nav linking every screen.`,
751
842
  contents: `<script lang="ts">
752
843
  import '../app.css'
753
- import { page } from '$app/stores'${opts.access ? `\n import { currentRole, canScreen } from '$lib/access'` : ''}${opts.i18n ? `\n import { t, currentLocale, locales } from '$lib/i18n'` : ''}
844
+ import '../custom.css'
845
+ import { page } from '$app/stores'${opts.access ? `\n import { currentRole, canScreen } from '$lib/access'` : ''}${opts.i18n ? `\n import { t, currentLocale, locales } from '$lib/i18n'` : ''}${opts.auth ? `\n import type { LayoutData } from './$types'` : ''}
754
846
 
755
- let { children } = $props()
847
+ let { children${opts.auth ? ', data' : ''} }${opts.auth ? ": { children: import('svelte').Snippet; data: LayoutData }" : ''} = $props()
756
848
  const nav = ${JSON.stringify(links)}
757
849
  const brand = ${JSON.stringify(brand)}${footConst}${logoConst}
758
- let navOpen = $state(false)
850
+ let navOpen = $state(false)${opts.auth && opts.access ? `\n // Seed the client role store from the signed-in session (server-resolved).\n $effect(() => { if (data?.role) currentRole.set(data.role as never) })` : ''}
759
851
  // Close the mobile drawer whenever the route changes.
760
- $effect(() => { void $page.url.pathname; navOpen = false })${sideState}${toolbarOn ? `
852
+ $effect(() => { void $page.url.pathname; navOpen = false })${hasSwitch ? `
853
+ // Light/dark switcher: defaults to the mode picked in Studio, then honours the
854
+ // visitor's saved choice. Applies via [data-theme] on <html> (see the token
855
+ // sets in <svelte:head>), and persists per browser.
856
+ let theme = $state<'light' | 'dark'>('${defaultMode}')
857
+ function applyTheme(t: 'light' | 'dark') {
858
+ theme = t
859
+ try { document.documentElement.dataset.theme = t } catch (_) { /* no DOM */ }
860
+ try { localStorage.setItem('svapp:theme', t) } catch (_) { /* storage blocked */ }
861
+ }
862
+ function toggleTheme() { applyTheme(theme === 'dark' ? 'light' : 'dark') }
863
+ $effect(() => {
864
+ let t: 'light' | 'dark' = '${defaultMode}'
865
+ try { const s = localStorage.getItem('svapp:theme'); if (s === 'light' || s === 'dark') t = s } catch (_) { /* storage blocked */ }
866
+ applyTheme(t)
867
+ })` : ''}${sideState}${toolbarOn ? `
761
868
  // App-chrome quick-search: filter the screens by label as you type.
762
869
  const initials = ${JSON.stringify(initials)}
763
870
  const acctEmail = ${JSON.stringify(`admin@${brandSlug}.com`)}
@@ -785,7 +892,11 @@ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: Shel
785
892
  </script>
786
893
  ${themeHead}
787
894
 
895
+ ${opts.auth ? `{#if ${JSON.stringify(opts.authRoutes ?? ['/login'])}.includes($page.url.pathname)}
896
+ {@render children()}
897
+ {:else}
788
898
  ${body}
899
+ {/if}` : body}
789
900
 
790
901
  <style>
791
902
  ${styles}
@@ -831,7 +942,13 @@ ${styles}
831
942
  .sv-app__menu-item { display: block; width: 100%; text-align: left; padding: 8px 10px; font: inherit; font-size: 13px; color: var(--sg-fg, #334155); background: none; border: none; border-radius: 8px; text-decoration: none; cursor: pointer; }
832
943
  .sv-app__menu-item:hover { background: color-mix(in srgb, var(--sg-accent, #6366f1) 10%, transparent); color: var(--sg-accent, #6366f1); }
833
944
  .sv-app__menu-item--danger:hover { background: color-mix(in srgb, #ef4444 12%, transparent); color: #ef4444; }
945
+ .sv-app__signout { margin: 0; display: block; }
946
+ .sv-app__signout .sv-app__menu-item { width: 100%; }
834
947
  .sv-app__locale { margin-top: 10px; padding: 5px 8px; font: inherit; font-size: 12.5px; color: var(--sg-fg, #0f172a); background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 8px; }
948
+ .sv-app__theme { align-items: center; justify-content: center; cursor: pointer; }
949
+ /* No-toolbar fallback slot: trails the nav, pushed to the far edge in row bars. */
950
+ .sv-app__navtools { display: flex; align-items: center; }
951
+ .sv-app--top .sv-app__navtools, .sv-app__bar--bottom .sv-app__navtools { margin-left: auto; }
835
952
  /* Mobile bar + drawer chrome (hidden on desktop; media queries above switch it on) */
836
953
  .sv-app__mobilebar { display: none; align-items: center; gap: 12px; padding: 10px 14px; border-bottom: 1px solid var(--sg-border, #e6e8ec); background: var(--sg-header-bg, #f8fafc); position: sticky; top: 0; z-index: 50; }
837
954
  .sv-app__burger { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; padding: 0; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 9px; background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); cursor: pointer; }
@@ -894,13 +1011,13 @@ export function prepareEntities(schemas: EntitySchema[]): { entries: Prepared[];
894
1011
  /** Emit the shared entity modules: `src/lib/schemas.ts` + `src/lib/data.ts` (+ `connections.ts`). */
895
1012
  export function emitEntityModules(
896
1013
  schemas: EntitySchema[],
897
- opts: { sources?: Record<string, EntityDataSource>; accessEnabled?: boolean; auditEnabled?: boolean } = {},
1014
+ opts: { sources?: Record<string, EntityDataSource>; accessEnabled?: boolean; auditEnabled?: boolean; screensByEntity?: Map<string, string[]>; triggers?: Record<string, EntityTriggers> } = {},
898
1015
  ): { files: GeneratedFile[]; prepared: EntitySchema[] } {
899
1016
  const { entries, seed } = prepareEntities(schemas)
900
1017
  const { file: data, needs } = dataModule(entries, seed, opts.sources)
901
1018
  const files: GeneratedFile[] = [schemasModule(entries), data]
902
1019
  if (needs.supabase) files.push(connectionsModule(needs))
903
- for (const r of needs.sqlRoutes) files.push(sqlRouteFile(r.schema, r.table, r.dialect, { access: opts.accessEnabled, audit: opts.auditEnabled }))
1020
+ for (const r of needs.sqlRoutes) files.push(sqlRouteFile(r.schema, r.table, r.dialect, { access: opts.accessEnabled, audit: opts.auditEnabled, screenIds: opts.screensByEntity?.get(r.schema.name) ?? [], triggers: opts.triggers?.[r.schema.name] }))
904
1021
  return { files, prepared: entries.map((e) => e.schema) }
905
1022
  }
906
1023