@svgrid/enterprise 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/dist/cdn/svgrid-enterprise.svelte-external.js +26039 -805
  2. package/dist/designer/assets/index-Dp44bTid.js +939 -0
  3. package/dist/designer/assets/index-RJp6x8tw.css +1 -0
  4. package/dist/designer/assets/jszip.min-CjMo-QGg.js +2 -0
  5. package/dist/designer/index.html +13 -0
  6. package/dist/node/studio.js +22554 -0
  7. package/package.json +10 -3
  8. package/src/SvAuthGate.svelte +115 -0
  9. package/src/SvBoard.dom.test.ts +67 -0
  10. package/src/SvBoard.svelte +192 -0
  11. package/src/SvExportMenu.svelte +553 -0
  12. package/src/SvFileInput.svelte +113 -0
  13. package/src/SvGridEditPanel.dom.test.ts +146 -0
  14. package/src/SvGridEditPanel.svelte +793 -0
  15. package/src/SvGridMasterDetail.svelte +95 -0
  16. package/src/SvImportDialog.svelte +1020 -0
  17. package/src/SvLookupInput.svelte +180 -0
  18. package/src/SvRecordDetail.dom.test.ts +137 -0
  19. package/src/SvRecordDetail.svelte +288 -0
  20. package/src/SvSchedule.dom.test.ts +57 -0
  21. package/src/SvSchedule.svelte +156 -0
  22. package/src/SvSchemaChart.svelte +233 -0
  23. package/src/SvSchemaDashboard.svelte +130 -0
  24. package/src/ai-export-pdf.test.ts +74 -0
  25. package/src/ai-export-xlsx.test.ts +87 -0
  26. package/src/ai-export.test.ts +110 -0
  27. package/src/ai.ts +1188 -782
  28. package/src/edit-panel.test.ts +213 -0
  29. package/src/edit-panel.ts +228 -0
  30. package/src/export-conditional.test.ts +60 -0
  31. package/src/export-conditional.ts +94 -0
  32. package/src/export-ooxml.test.ts +152 -0
  33. package/src/export-ooxml.ts +485 -0
  34. package/src/export-pdf.test.ts +138 -0
  35. package/src/export-pdf.ts +245 -0
  36. package/src/export-print.test.ts +66 -0
  37. package/src/export-print.ts +132 -0
  38. package/src/export-serialize.test.ts +56 -0
  39. package/src/export-serialize.ts +24 -0
  40. package/src/export-xls.ts +196 -0
  41. package/src/export-xlsx-roundtrip.test.ts +120 -0
  42. package/src/export-xlsx.test.ts +150 -0
  43. package/src/export.test.ts +349 -0
  44. package/src/export.ts +1452 -549
  45. package/src/import-automap.test.ts +131 -0
  46. package/src/import-hardening.test.ts +158 -0
  47. package/src/import.ts +1042 -648
  48. package/src/index.ts +282 -0
  49. package/src/install.ts +134 -114
  50. package/src/license-core.test.ts +23 -0
  51. package/src/license-core.ts +35 -0
  52. package/src/license.ts +103 -90
  53. package/src/master-detail.test.ts +61 -0
  54. package/src/master-detail.ts +42 -0
  55. package/src/print.ts +107 -127
  56. package/src/schema-designer.test.ts +121 -0
  57. package/src/schema-designer.ts +142 -0
  58. package/src/schema.test.ts +268 -0
  59. package/src/schema.ts +499 -0
  60. package/src/smart-shim.ts +107 -105
  61. package/src/sources/aggregate.test.ts +79 -0
  62. package/src/sources/aggregate.ts +102 -0
  63. package/src/sources/auth-supabase.test.ts +80 -0
  64. package/src/sources/auth-supabase.ts +87 -0
  65. package/src/sources/dashboard.kpi.test.ts +50 -0
  66. package/src/sources/dashboard.test.ts +61 -0
  67. package/src/sources/dashboard.ts +136 -0
  68. package/src/sources/field-inference.test.ts +60 -0
  69. package/src/sources/field-inference.ts +63 -0
  70. package/src/sources/filters.test.ts +58 -0
  71. package/src/sources/filters.ts +64 -0
  72. package/src/sources/index.ts +67 -0
  73. package/src/sources/introspect-supabase.test.ts +100 -0
  74. package/src/sources/introspect-supabase.ts +119 -0
  75. package/src/sources/realtime-supabase.test.ts +93 -0
  76. package/src/sources/realtime-supabase.ts +99 -0
  77. package/src/sources/relation-lookup.test.ts +99 -0
  78. package/src/sources/relation-lookup.ts +128 -0
  79. package/src/sources/rest-adapters.test.ts +95 -0
  80. package/src/sources/rest-adapters.ts +120 -0
  81. package/src/sources/rest.test.ts +104 -0
  82. package/src/sources/rest.ts +129 -0
  83. package/src/sources/schema-from-columns.test.ts +106 -0
  84. package/src/sources/schema-from-columns.ts +88 -0
  85. package/src/sources/supabase.test.ts +110 -0
  86. package/src/sources/supabase.ts +99 -0
  87. package/src/sources/with-entity-rules.test.ts +104 -0
  88. package/src/sources/with-entity-rules.ts +78 -0
  89. package/src/sources/with-relation-labels.test.ts +75 -0
  90. package/src/sources/with-relation-labels.ts +73 -0
  91. package/src/studio/bug-report.test.ts +101 -0
  92. package/src/studio/bug-report.ts +165 -0
  93. package/src/studio/cli.test.ts +187 -0
  94. package/src/studio/cli.ts +112 -0
  95. package/src/studio/csv.test.ts +90 -0
  96. package/src/studio/csv.ts +165 -0
  97. package/src/studio/db-connect-string.test.ts +94 -0
  98. package/src/studio/db-connect-string.ts +128 -0
  99. package/src/studio/emit-project.test.ts +923 -0
  100. package/src/studio/emit-project.ts +1273 -0
  101. package/src/studio/emit-schema.test.ts +94 -0
  102. package/src/studio/emit-schema.ts +920 -0
  103. package/src/studio/index.ts +195 -0
  104. package/src/studio/introspect-db.test.ts +186 -0
  105. package/src/studio/introspect-db.ts +312 -0
  106. package/src/studio/introspect-prisma.test.ts +99 -0
  107. package/src/studio/introspect-prisma.ts +175 -0
  108. package/src/studio/introspect.test.ts +172 -0
  109. package/src/studio/introspect.ts +310 -0
  110. package/src/studio/pipeline.test.ts +42 -0
  111. package/src/studio/project-robust.test.ts +157 -0
  112. package/src/studio/project.test.ts +473 -0
  113. package/src/studio/project.ts +916 -0
  114. package/src/studio/sample-data.test.ts +58 -0
  115. package/src/studio/sample-data.ts +200 -0
  116. package/src/studio/samples/ats.ts +202 -0
  117. package/src/studio/samples/clinic.ts +177 -0
  118. package/src/studio/samples/crm.ts +250 -0
  119. package/src/studio/samples/ecommerce.ts +187 -0
  120. package/src/studio/samples/events.ts +199 -0
  121. package/src/studio/samples/fleet.ts +184 -0
  122. package/src/studio/samples/gym.ts +213 -0
  123. package/src/studio/samples/hr.ts +193 -0
  124. package/src/studio/samples/index.ts +55 -0
  125. package/src/studio/samples/insurance.ts +195 -0
  126. package/src/studio/samples/inventory.ts +182 -0
  127. package/src/studio/samples/invoicing.ts +162 -0
  128. package/src/studio/samples/library.ts +180 -0
  129. package/src/studio/samples/live-data.test.ts +98 -0
  130. package/src/studio/samples/live-data.ts +308 -0
  131. package/src/studio/samples/projects.ts +190 -0
  132. package/src/studio/samples/realestate.ts +196 -0
  133. package/src/studio/samples/restaurant.ts +187 -0
  134. package/src/studio/samples/samples.test.ts +208 -0
  135. package/src/studio/samples/school.ts +197 -0
  136. package/src/studio/samples/seed-floor.test.ts +25 -0
  137. package/src/studio/samples/shared.ts +305 -0
  138. package/src/studio/samples/subscriptions.ts +183 -0
  139. package/src/studio/samples/support.ts +182 -0
  140. package/src/studio/scaffold-app.test.ts +91 -0
  141. package/src/studio/scaffold-app.ts +161 -0
  142. package/src/studio/scaffold.test.ts +178 -0
  143. package/src/studio/scaffold.ts +374 -0
  144. package/src/studio/themes.ts +172 -0
  145. package/src/studio/verify.test.ts +47 -0
  146. package/src/studio/verify.ts +79 -0
  147. package/src/sveltekit/in-memory.test.ts +104 -0
  148. package/src/sveltekit/in-memory.ts +129 -0
  149. package/src/sveltekit/index.ts +23 -0
  150. package/src/sveltekit/query-plan.test.ts +109 -0
  151. package/src/sveltekit/query-plan.ts +125 -0
  152. package/src/sveltekit/sql-source.test.ts +185 -0
  153. package/src/sveltekit/sql-source.ts +166 -0
  154. package/src/sveltekit/sql.test.ts +80 -0
  155. package/src/sveltekit/sql.ts +131 -0
  156. package/src/sveltekit/transport.test.ts +175 -0
  157. package/src/sveltekit/transport.ts +254 -0
  158. package/src/sveltekit/types.ts +11 -0
  159. package/src/upgrade-prompt.ts +149 -148
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svgrid/enterprise",
3
- "version": "1.2.0",
3
+ "version": "2.0.0",
4
4
  "description": "Pro feature pack for sv-grid: data export (Excel, PDF, CSV, TSV, HTML), import (xlsx, CSV, TSV, JSON), pivot table + designer, AI assistant (BYO model), and printing. Requires a paid license key.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "jQWidgets <sales@jqwidgets.com>",
@@ -37,6 +37,12 @@
37
37
  "svelte": "./src/pivot.ts",
38
38
  "import": "./src/pivot.ts"
39
39
  },
40
+ "./studio": {
41
+ "types": "./src/studio/index.ts",
42
+ "node": "./dist/node/studio.js",
43
+ "svelte": "./src/studio/index.ts",
44
+ "import": "./src/studio/index.ts"
45
+ },
40
46
  "./cdn/svelte-external": {
41
47
  "import": "./dist/cdn/svgrid-enterprise.svelte-external.js",
42
48
  "default": "./dist/cdn/svgrid-enterprise.svelte-external.js"
@@ -54,9 +60,10 @@
54
60
  "scripts": {
55
61
  "test": "vitest run",
56
62
  "test:types": "tsc -p tsconfig.json --noEmit",
57
- "build": "node ./scripts/build-cdn.mjs",
63
+ "build": "node ./scripts/build-cdn.mjs && node ./scripts/build-node.mjs",
58
64
  "build:cdn": "node ./scripts/build-cdn.mjs",
59
- "prepublishOnly": "node ./scripts/build-cdn.mjs"
65
+ "build:node": "node ./scripts/build-node.mjs",
66
+ "prepublishOnly": "node ./scripts/build-cdn.mjs && node ./scripts/build-node.mjs"
60
67
  },
61
68
  "peerDependencies": {
62
69
  "svelte": "^5.0.0",
@@ -0,0 +1,115 @@
1
+ <script lang="ts">
2
+ /**
3
+ * SvAuthGate - require a signed-in user before showing its content. Renders a
4
+ * login / sign-up form when signed out (Supabase Auth via `createSupabaseAuth`)
5
+ * and its children when signed in, with a small "signed in as ... / Sign out"
6
+ * bar. Pair with Row-Level Security so each user sees only their own rows.
7
+ *
8
+ * <SvAuthGate {client}>
9
+ * <MyStudioScreen />
10
+ * </SvAuthGate>
11
+ */
12
+ import type { Snippet } from 'svelte'
13
+ import { createSupabaseAuth, type AuthState, type SupabaseAuthClientLike, type SupabaseAuthController } from './sources/auth-supabase'
14
+
15
+ type Props = {
16
+ client: SupabaseAuthClientLike
17
+ children: Snippet
18
+ title?: string
19
+ /** Show the "signed in as ... / Sign out" bar. Default true. */
20
+ showBar?: boolean
21
+ }
22
+
23
+ let { client, children, title = 'Sign in', showBar = true }: Props = $props()
24
+
25
+ let auth = $state<SupabaseAuthController | undefined>()
26
+ let authState = $state<AuthState>({ user: null, loading: true, error: null })
27
+ let mode = $state<'signin' | 'signup'>('signin')
28
+ let email = $state('')
29
+ let password = $state('')
30
+
31
+ $effect(() => {
32
+ const controller = createSupabaseAuth({ client, onChange: (s) => (authState = s) })
33
+ auth = controller
34
+ return () => controller.dispose()
35
+ })
36
+
37
+ function submit(e: SubmitEvent) {
38
+ e.preventDefault()
39
+ if (mode === 'signin') auth?.signIn(email, password)
40
+ else auth?.signUp(email, password)
41
+ }
42
+ </script>
43
+
44
+ {#if authState.user}
45
+ {#if showBar}
46
+ <div class="sv-auth-bar">
47
+ <span>Signed in as <strong>{authState.user.email ?? authState.user.id}</strong></span>
48
+ <button type="button" onclick={() => auth?.signOut()} disabled={authState.loading}>Sign out</button>
49
+ </div>
50
+ {/if}
51
+ {@render children()}
52
+ {:else}
53
+ <div class="sv-auth">
54
+ <form class="sv-auth__form" onsubmit={submit}>
55
+ <h2 class="sv-auth__title">{mode === 'signin' ? title : 'Create account'}</h2>
56
+
57
+ <label class="sv-auth__field">
58
+ <span>Email</span>
59
+ <input type="email" autocomplete="email" bind:value={email} required />
60
+ </label>
61
+ <label class="sv-auth__field">
62
+ <span>Password</span>
63
+ <input type="password" autocomplete="current-password" bind:value={password} required />
64
+ </label>
65
+
66
+ {#if authState.error}<p class="sv-auth__error">{authState.error}</p>{/if}
67
+
68
+ <button class="sv-auth__submit" type="submit" disabled={authState.loading}>
69
+ {authState.loading ? 'Please wait…' : mode === 'signin' ? 'Sign in' : 'Create account'}
70
+ </button>
71
+
72
+ <button
73
+ type="button"
74
+ class="sv-auth__toggle"
75
+ onclick={() => (mode = mode === 'signin' ? 'signup' : 'signin')}
76
+ >
77
+ {mode === 'signin' ? 'Need an account? Sign up' : 'Have an account? Sign in'}
78
+ </button>
79
+ </form>
80
+ </div>
81
+ {/if}
82
+
83
+ <style>
84
+ .sv-auth { display: flex; justify-content: center; padding: 32px 16px; }
85
+ .sv-auth__form {
86
+ display: flex; flex-direction: column; gap: 12px; width: 100%; max-width: 320px;
87
+ padding: 24px; border: 1px solid var(--sg-border, #e2e2e2); border-radius: 12px;
88
+ background: var(--sg-bg, #fff); color: var(--sg-fg, inherit);
89
+ }
90
+ .sv-auth__title { margin: 0 0 4px; font-size: 18px; font-weight: 600; }
91
+ .sv-auth__field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; font-weight: 600; color: var(--sg-muted, #555); }
92
+ .sv-auth__field input {
93
+ padding: 8px 10px; border: 1px solid var(--sg-border, #ccc); border-radius: 6px;
94
+ background: var(--sg-input-bg, #fff); color: var(--sg-fg, inherit); font: inherit;
95
+ }
96
+ .sv-auth__error { margin: 0; font-size: 13px; color: #dc2626; }
97
+ .sv-auth__submit {
98
+ padding: 9px 12px; border: 1px solid var(--sg-accent, #2563eb); border-radius: 6px;
99
+ background: var(--sg-accent, #2563eb); color: #fff; cursor: pointer; font: inherit; font-weight: 600;
100
+ }
101
+ .sv-auth__submit:disabled { opacity: 0.6; cursor: default; }
102
+ .sv-auth__toggle { border: none; background: none; color: var(--sg-accent, #2563eb); cursor: pointer; font: inherit; font-size: 13px; }
103
+
104
+ .sv-auth-bar {
105
+ display: flex; align-items: center; justify-content: space-between; gap: 12px;
106
+ padding: 8px 12px; margin-bottom: 12px; font-size: 13px;
107
+ border: 1px solid var(--sg-border, #e2e2e2); border-radius: 8px;
108
+ background: var(--sg-header-bg, #f8fafc); color: var(--sg-fg, inherit);
109
+ }
110
+ .sv-auth-bar button {
111
+ padding: 5px 10px; border: 1px solid var(--sg-border, #ccc); border-radius: 6px;
112
+ background: var(--sg-bg, #fff); color: var(--sg-fg, inherit); cursor: pointer; font: inherit;
113
+ }
114
+ .sv-auth-bar button:disabled { opacity: 0.5; cursor: default; }
115
+ </style>
@@ -0,0 +1,67 @@
1
+ /**
2
+ * DOM test: SvBoard renders an enum field's options as columns and each row as a
3
+ * card in its column, with counts and badges. Mounts against the real CRM deals
4
+ * schema so the board <-> sample path is covered.
5
+ */
6
+ import { describe, it, expect, afterEach, vi } from 'vitest'
7
+ import { mount, unmount, flushSync } from 'svelte'
8
+ import SvBoard from './SvBoard.svelte'
9
+ import { getSampleApp } from './studio/samples/index'
10
+ import type { EntitySchema } from './schema'
11
+
12
+ const dealsSchema: EntitySchema = {
13
+ name: 'deals',
14
+ idField: 'id',
15
+ fields: [
16
+ { field: 'id', type: 'text', primaryKey: true, readonly: true },
17
+ { field: 'title', type: 'text' },
18
+ { field: 'value', type: 'number', label: 'Value ($)' },
19
+ { field: 'stage', type: 'enum', options: [
20
+ { value: 'lead', label: 'Lead', color: '#94a3b8' },
21
+ { value: 'won', label: 'Won', color: '#10b981' },
22
+ { value: 'lost', label: 'Lost', color: '#ef4444' },
23
+ ] },
24
+ ],
25
+ }
26
+
27
+ let host: HTMLElement | null = null
28
+ let comp: ReturnType<typeof mount> | null = null
29
+ afterEach(() => { if (comp) { unmount(comp); comp = null } if (host) { host.remove(); host = null } })
30
+
31
+ function render(props: Record<string, unknown>): HTMLElement {
32
+ host = document.createElement('div')
33
+ document.body.appendChild(host)
34
+ comp = mount(SvBoard, { target: host, props })
35
+ flushSync()
36
+ return host
37
+ }
38
+
39
+ describe('SvBoard (DOM)', () => {
40
+ it('renders a column per enum option and a card per row', () => {
41
+ const rows = [
42
+ { id: 'd1', title: 'Alpha deal', value: 1000, stage: 'lead' },
43
+ { id: 'd2', title: 'Beta deal', value: 2000, stage: 'won' },
44
+ { id: 'd3', title: 'Gamma deal', value: 500, stage: 'lead' },
45
+ ]
46
+ const el = render({ schema: dealsSchema, rows, groupBy: 'stage', titleField: 'title', badgeField: 'value' })
47
+ const cols = el.querySelectorAll('.sv-board__col')
48
+ expect(cols.length).toBe(3) // lead / won / lost
49
+ // Column titles + counts.
50
+ const titles = [...el.querySelectorAll('.sv-board__coltitle')].map((n) => n.textContent)
51
+ expect(titles).toEqual(['Lead', 'Won', 'Lost'])
52
+ const counts = [...el.querySelectorAll('.sv-board__count')].map((n) => n.textContent)
53
+ expect(counts).toEqual(['2', '1', '0'])
54
+ // Cards + a formatted money badge ($ from the "Value ($)" label).
55
+ const cards = [...el.querySelectorAll('.sv-board__card-title')].map((n) => n.textContent)
56
+ expect(cards).toContain('Alpha deal')
57
+ expect(el.textContent).toContain('$1,000')
58
+ })
59
+
60
+ it('mounts against the real CRM deals sample', () => {
61
+ const proj = getSampleApp('crm')!.build()
62
+ const rows = (proj.dataSources!.deals as { seed?: Record<string, unknown>[] }).seed!
63
+ const el = render({ schema: proj.entities.find((e) => e.name === 'deals'), rows, groupBy: 'stage', titleField: 'title', badgeField: 'value', onMove: vi.fn() })
64
+ expect(el.querySelectorAll('.sv-board__col').length).toBe(5) // 5 deal stages
65
+ expect(el.querySelectorAll('.sv-board__card').length).toBe(rows.length)
66
+ })
67
+ })
@@ -0,0 +1,192 @@
1
+ <script lang="ts">
2
+ /**
3
+ * SvBoard - a Kanban board over an entity's rows. Columns are the options of an
4
+ * `enum` field (`groupBy`); each row is a card grouped into its column. Drag a
5
+ * card to another column to change its `groupBy` value (fires `onMove`). A real
6
+ * pipeline / task / ticket board, not a report - the signature view that makes a
7
+ * Studio app read like a product.
8
+ *
9
+ * Pure presentation over plain rows + an EntitySchema (same inputs as
10
+ * SvSchemaChart / SvGridEditPanel), so both the generated app and the designer
11
+ * preview render it identically.
12
+ */
13
+ import type { EntitySchema, EntityField } from './schema'
14
+
15
+ type Row = Record<string, unknown>
16
+
17
+ let {
18
+ schema,
19
+ rows = [],
20
+ groupBy,
21
+ titleField,
22
+ badgeField,
23
+ subtitleField,
24
+ onMove,
25
+ onOpen,
26
+ loading = false,
27
+ height,
28
+ }: {
29
+ schema: EntitySchema
30
+ rows?: ReadonlyArray<Row>
31
+ /** Show shimmer placeholders (first load) instead of columns. */
32
+ loading?: boolean
33
+ /** The enum field whose options become the columns. */
34
+ groupBy: string
35
+ /** Field shown as the card title. */
36
+ titleField: string
37
+ /** Optional numeric/text field shown as a chip on the card. */
38
+ badgeField?: string
39
+ /** Optional secondary line on the card. */
40
+ subtitleField?: string
41
+ /** Called with (rowId, newColumnValue) when a card is dropped in a column. */
42
+ onMove?: (id: string, value: string) => void
43
+ /** Called with the card's row id when it is clicked (drill to a detail page). */
44
+ onOpen?: (id: string) => void
45
+ height?: number
46
+ } = $props()
47
+
48
+ const idField = $derived(schema.idField ?? schema.fields.find((f) => f.primaryKey)?.field ?? 'id')
49
+ const fieldOf = (name?: string): EntityField | undefined => (name ? schema.fields.find((f) => f.field === name) : undefined)
50
+ const groupField = $derived(fieldOf(groupBy))
51
+ const badgeIsMoney = $derived(/\$/.test(fieldOf(badgeField)?.label ?? ''))
52
+
53
+ /** The columns: the groupBy field's enum options (value + label + color). */
54
+ const columns = $derived(
55
+ (groupField?.options ?? []).map((o) => ({ value: String(o.value), label: o.label ?? String(o.value), color: o.color ?? 'var(--sg-accent, #6366f1)' })),
56
+ )
57
+
58
+ const cardsFor = (value: string): Row[] => rows.filter((r) => String(r[groupBy] ?? '') === value)
59
+
60
+ function badgeText(r: Row): string {
61
+ if (!badgeField) return ''
62
+ const v = r[badgeField]
63
+ if (v == null || v === '') return ''
64
+ if (typeof v === 'number') return badgeIsMoney ? '$' + v.toLocaleString(undefined, { maximumFractionDigits: 0 }) : v.toLocaleString()
65
+ return String(v)
66
+ }
67
+
68
+ let dragId = $state<string | null>(null)
69
+ let overCol = $state<string | null>(null)
70
+ function onDragStart(e: DragEvent, id: string) {
71
+ dragId = id
72
+ if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', id) }
73
+ }
74
+ function onDrop(value: string) {
75
+ if (dragId != null) {
76
+ const from = rows.find((r) => String(r[idField]) === dragId)
77
+ if (from && String(from[groupBy] ?? '') !== value) onMove?.(dragId, value)
78
+ }
79
+ dragId = null
80
+ overCol = null
81
+ }
82
+ </script>
83
+
84
+ <div class="sv-board" style:height={height != null ? `${height}px` : undefined}>
85
+ {#if loading}
86
+ {#each [0, 1, 2] as c (c)}
87
+ <section class="sv-board__col sv-board__col--skeleton">
88
+ <header class="sv-board__colhead"><span class="sv-board__sk sv-board__sk--head"></span></header>
89
+ <div class="sv-board__cards">
90
+ {#each [0, 1] as k (k)}<div class="sv-board__card sv-board__sk sv-board__sk--card"></div>{/each}
91
+ </div>
92
+ </section>
93
+ {/each}
94
+ {:else if columns.length > 0 && rows.length === 0}
95
+ <div class="sv-board__blank">
96
+ <div class="sv-board__blank-mark" aria-hidden="true"></div>
97
+ <p>No records yet. New items will appear as cards here.</p>
98
+ </div>
99
+ {:else}
100
+ {#each columns as col (col.value)}
101
+ {@const cards = cardsFor(col.value)}
102
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
103
+ <section
104
+ class="sv-board__col"
105
+ class:is-over={overCol === col.value}
106
+ ondragover={(e) => { e.preventDefault(); overCol = col.value }}
107
+ ondragleave={() => { if (overCol === col.value) overCol = null }}
108
+ ondrop={(e) => { e.preventDefault(); onDrop(col.value) }}
109
+ >
110
+ <header class="sv-board__colhead" style:--col={col.color}>
111
+ <span class="sv-board__dot"></span>
112
+ <span class="sv-board__coltitle">{col.label}</span>
113
+ <span class="sv-board__count">{cards.length}</span>
114
+ </header>
115
+ <div class="sv-board__cards">
116
+ {#each cards as r (String(r[idField]))}
117
+ <!-- svelte-ignore a11y_no_static_element_interactions a11y_no_noninteractive_tabindex -->
118
+ <article
119
+ class="sv-board__card"
120
+ class:is-dragging={dragId === String(r[idField])}
121
+ class:is-clickable={!!onOpen}
122
+ style:--col={col.color}
123
+ draggable="true"
124
+ ondragstart={(e) => onDragStart(e, String(r[idField]))}
125
+ ondragend={() => { dragId = null; overCol = null }}
126
+ role={onOpen ? 'button' : undefined}
127
+ tabindex={onOpen ? 0 : undefined}
128
+ onclick={onOpen ? () => onOpen(String(r[idField])) : undefined}
129
+ onkeydown={onOpen ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen(String(r[idField])) } } : undefined}
130
+ >
131
+ <div class="sv-board__card-title">{String(r[titleField] ?? '(untitled)')}</div>
132
+ {#if subtitleField && r[subtitleField] != null && r[subtitleField] !== ''}
133
+ <div class="sv-board__card-sub">{String(r[subtitleField])}</div>
134
+ {/if}
135
+ {#if badgeText(r)}<span class="sv-board__badge">{badgeText(r)}</span>{/if}
136
+ </article>
137
+ {/each}
138
+ {#if cards.length === 0}<div class="sv-board__empty">Drop here</div>{/if}
139
+ </div>
140
+ </section>
141
+ {/each}
142
+ {#if columns.length === 0}
143
+ <p class="sv-board__hint">Set <code>groupBy</code> to an enum field to build the board columns.</p>
144
+ {/if}
145
+ {/if}
146
+ </div>
147
+
148
+ <style>
149
+ .sv-board {
150
+ display: flex; gap: 14px; align-items: flex-start; overflow-x: auto; padding: 4px;
151
+ min-height: 260px; box-sizing: border-box;
152
+ }
153
+ .sv-board__col {
154
+ flex: 0 0 clamp(240px, 22vw, 300px); display: flex; flex-direction: column; gap: 10px; min-height: 120px;
155
+ background: var(--sg-header-bg, #f6f7f9); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px;
156
+ padding: 12px; transition: background 0.12s, box-shadow 0.12s;
157
+ }
158
+ .sv-board__col.is-over { background: color-mix(in srgb, var(--sg-accent, #6366f1) 10%, var(--sg-header-bg, #f6f7f9)); box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--sg-accent, #6366f1) 40%, transparent); }
159
+ .sv-board__colhead { display: flex; align-items: center; gap: 8px; padding: 0 2px 2px; }
160
+ .sv-board__dot { width: 9px; height: 9px; border-radius: 50%; background: var(--col, var(--sg-accent, #6366f1)); flex: none; }
161
+ .sv-board__coltitle { font-size: 12.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: var(--sg-fg, #0f172a); }
162
+ .sv-board__count { margin-left: auto; min-width: 20px; height: 20px; padding: 0 6px; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; color: var(--sg-muted, #64748b); background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 999px; }
163
+ .sv-board__cards { display: flex; flex-direction: column; gap: 8px; min-height: 40px; }
164
+ .sv-board__card {
165
+ position: relative; background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-left: 3px solid var(--col, var(--sg-accent, #6366f1));
166
+ border-radius: 9px; padding: 10px 12px; cursor: grab; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); user-select: none;
167
+ transition: box-shadow 0.12s, transform 0.06s;
168
+ }
169
+ .sv-board__card:hover { box-shadow: 0 4px 12px -4px rgba(15, 23, 42, 0.18); }
170
+ .sv-board__card.is-clickable:hover { border-color: color-mix(in srgb, var(--col, #6366f1) 45%, var(--sg-border, #e6e8ec)); }
171
+ .sv-board__card.is-clickable:focus-visible { outline: 2px solid var(--col, #6366f1); outline-offset: 2px; }
172
+ .sv-board__card:active { cursor: grabbing; }
173
+ .sv-board__card.is-dragging { opacity: 0.5; }
174
+ .sv-board__card-title { font-size: 13.5px; font-weight: 600; color: var(--sg-fg, #0f172a); line-height: 1.35; }
175
+ .sv-board__card-sub { margin-top: 3px; font-size: 12px; color: var(--sg-muted, #64748b); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
176
+ .sv-board__badge {
177
+ display: inline-flex; margin-top: 8px; padding: 2px 8px; font-size: 11.5px; font-weight: 700; border-radius: 999px;
178
+ color: var(--col, var(--sg-accent, #6366f1)); background: color-mix(in srgb, var(--col, #6366f1) 14%, transparent);
179
+ }
180
+ .sv-board__empty { padding: 14px; text-align: center; font-size: 12px; color: var(--sg-muted, #94a3b8); border: 1px dashed var(--sg-border, #e6e8ec); border-radius: 8px; }
181
+ .sv-board__hint { color: var(--sg-muted, #64748b); font-size: 13px; padding: 20px; }
182
+ .sv-board__hint code { background: var(--sg-header-bg, #f1f5f9); padding: 1px 5px; border-radius: 5px; }
183
+ /* First-load skeleton + empty state. */
184
+ .sv-board__col--skeleton { pointer-events: none; }
185
+ .sv-board__sk { position: relative; overflow: hidden; background: color-mix(in srgb, var(--sg-fg, #0f172a) 7%, transparent); border: none; }
186
+ .sv-board__sk::after { content: ''; position: absolute; inset: 0; transform: translateX(-100%); background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--sg-bg, #fff) 55%, transparent), transparent); animation: sv-board-sheen 1.2s ease-in-out infinite; }
187
+ .sv-board__sk--head { display: block; width: 55%; height: 12px; border-radius: 5px; }
188
+ .sv-board__sk--card { height: 52px; box-shadow: none; }
189
+ @keyframes sv-board-sheen { 100% { transform: translateX(100%); } }
190
+ .sv-board__blank { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; min-height: 200px; color: var(--sg-muted, #94a3b8); font-size: 13.5px; text-align: center; }
191
+ .sv-board__blank-mark { width: 42px; height: 42px; border-radius: 11px; border: 2px dashed var(--sg-border, #cbd5e1); }
192
+ </style>