@svgrid/mcp 2.5.0 → 2.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/data.js CHANGED
@@ -2546,8 +2546,8 @@ export const examples = [
2546
2546
  "id": "98-advanced-filter-builder",
2547
2547
  "path": "examples/src/demos/98-advanced-filter-builder.svelte",
2548
2548
  "title": "Advanced Filter Builder",
2549
- "blurb": "98. Advanced filter builder (visual query builder) A drag-and-drop AND/OR query builder, like Notion's filter panel or Linear's view filters. Compose any number of rule rows ANDed",
2550
- "source": "<script lang=\"ts\">\n /**\n * 98. Advanced filter builder (visual query builder)\n * --------------------------------------------------\n * A drag-and-drop AND/OR query builder, like Notion's filter panel\n * or Linear's view filters. Compose any number of rule rows ANDed\n * or ORed together; each rule picks (field × operator × value).\n * The filter runs entirely client-side over the dataset; we replace\n * the grid's data with the filtered subset on every change.\n *\n * The grid's own column menu still works for ad-hoc filtering - this\n * is the \"Saved view\" / \"Build a complex query\" surface that ships\n * with every modern BI tool.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n type ColumnDef,\n } from '@svgrid/grid'\n\n type Row = {\n id: string\n company: string\n region: 'Americas' | 'EMEA' | 'APAC'\n industry: 'SaaS' | 'Manufacturing' | 'Retail' | 'Healthcare' | 'Finance'\n arr: number\n seats: number\n churnRisk: 'low' | 'medium' | 'high'\n contractEnd: string\n healthScore: number\n }\n\n // ---- Seed data ---------------------------------------------------------\n let prng = 0xDEC0DE\n function rand() { prng = (prng * 1664525 + 1013904223) >>> 0; return prng / 0xFFFFFFFF }\n function pick<T>(a: readonly T[]): T { return a[Math.floor(rand() * a.length)]! }\n function int(min: number, max: number) { return Math.floor(min + rand() * (max - min + 1)) }\n\n const REGIONS = ['Americas', 'EMEA', 'APAC'] as const\n const INDUSTRIES = ['SaaS', 'Manufacturing', 'Retail', 'Healthcare', 'Finance'] as const\n const RISKS = ['low', 'medium', 'high'] as const\n const COMPANY_NAMES = [\n 'Helios', 'Vertex', 'Atlas', 'Quantum', 'Stellar', 'Apex', 'Crescent', 'Sigma',\n 'Pioneer', 'Aurora', 'Granite', 'Cobalt', 'Meridian', 'Polaris', 'Sentinel', 'Tessera',\n 'Cascade', 'Beacon', 'Wavelength', 'Lumen', 'Echo', 'Cipher', 'Nimbus', 'Caldera',\n 'Sterling', 'Onyx', 'Slate', 'Ember', 'Verdant', 'Halcyon',\n ]\n const SUFFIXES = ['Labs', 'Group', 'Holdings', 'Industries', 'Systems', 'Capital', 'Partners', 'Networks']\n\n let allRows: Row[] = Array.from({ length: 250 }, (_, i) => {\n const yearOffset = int(0, 18) // contract ends 0-18 months from now\n const d = new Date(); d.setMonth(d.getMonth() + yearOffset)\n return {\n id: `ACC-${(1000 + i).toString()}`,\n company: `${pick(COMPANY_NAMES)} ${pick(SUFFIXES)}`,\n region: pick(REGIONS),\n industry: pick(INDUSTRIES),\n arr: int(5_000, 480_000),\n seats: int(3, 240),\n churnRisk: pick(RISKS),\n contractEnd:d.toISOString().slice(0, 10),\n healthScore: int(15, 99),\n }\n })\n\n // ---- Field metadata for the builder ------------------------------------\n type FieldType = 'text' | 'number' | 'enum' | 'date'\n type FieldDef = {\n id: keyof Row\n label: string\n type: FieldType\n options?: readonly string[]\n }\n const FIELDS: FieldDef[] = [\n { id: 'company', label: 'Company', type: 'text' },\n { id: 'region', label: 'Region', type: 'enum', options: REGIONS },\n { id: 'industry', label: 'Industry', type: 'enum', options: INDUSTRIES },\n { id: 'arr', label: 'ARR', type: 'number' },\n { id: 'seats', label: 'Seats', type: 'number' },\n { id: 'churnRisk', label: 'Churn risk', type: 'enum', options: RISKS },\n { id: 'contractEnd', label: 'Contract end', type: 'date' },\n { id: 'healthScore', label: 'Health score', type: 'number' },\n ]\n\n type OperatorId =\n | 'contains' | 'equals' | 'notEquals' | 'startsWith'\n | 'gt' | 'lt' | 'gte' | 'lte' | 'between'\n | 'inSet'\n | 'before' | 'after' | 'within'\n type OperatorDef = { id: OperatorId; label: string; types: FieldType[]; nValues: 1 | 2 | 'set' }\n const OPERATORS: OperatorDef[] = [\n { id: 'contains', label: 'contains', types: ['text'], nValues: 1 },\n { id: 'equals', label: 'equals', types: ['text', 'enum'], nValues: 1 },\n { id: 'notEquals', label: 'not equals', types: ['text', 'enum'], nValues: 1 },\n { id: 'startsWith', label: 'starts with', types: ['text'], nValues: 1 },\n { id: 'inSet', label: 'is one of', types: ['enum'], nValues: 'set' },\n { id: 'gt', label: '>', types: ['number'], nValues: 1 },\n { id: 'lt', label: '<', types: ['number'], nValues: 1 },\n { id: 'gte', label: '≥', types: ['number'], nValues: 1 },\n { id: 'lte', label: '≤', types: ['number'], nValues: 1 },\n { id: 'between', label: 'between', types: ['number'], nValues: 2 },\n { id: 'before', label: 'before', types: ['date'], nValues: 1 },\n { id: 'after', label: 'after', types: ['date'], nValues: 1 },\n { id: 'within', label: 'within next N days', types: ['date'], nValues: 1 },\n ]\n function operatorsFor(type: FieldType) { return OPERATORS.filter((o) => o.types.includes(type)) }\n\n type Rule = {\n id: string\n field: keyof Row\n operator: OperatorId\n v1: string\n v2: string\n set: string[] // for 'inSet'\n }\n\n function newRule(): Rule {\n const f = FIELDS[0]!\n const op = operatorsFor(f.type)[0]!\n return { id: crypto.randomUUID(), field: f.id, operator: op.id, v1: '', v2: '', set: [] }\n }\n\n let combinator = $state<'AND' | 'OR'>('AND')\n let rules = $state<Rule[]>([\n { id: 'r1', field: 'region', operator: 'equals', v1: 'EMEA', v2: '', set: [] },\n { id: 'r2', field: 'arr', operator: 'gt', v1: '100000', v2: '', set: [] },\n { id: 'r3', field: 'churnRisk', operator: 'inSet', v1: '', v2: '', set: ['medium', 'high'] },\n ])\n let presetTouched = $state(false)\n\n function addRule() { rules = [...rules, newRule()]; presetTouched = true }\n function removeRule(id: string) { rules = rules.filter((r) => r.id !== id); presetTouched = true }\n function onFieldChange(rule: Rule, nextField: keyof Row) {\n const nextType = FIELDS.find((f) => f.id === nextField)!.type\n const stillValid = operatorsFor(nextType).find((o) => o.id === rule.operator)\n const nextOp = stillValid ?? operatorsFor(nextType)[0]!\n rules = rules.map((r) => r.id === rule.id\n ? { ...r, field: nextField, operator: nextOp.id, v1: '', v2: '', set: [] }\n : r)\n presetTouched = true\n }\n function updateRule(id: string, patch: Partial<Rule>) {\n rules = rules.map((r) => r.id === id ? { ...r, ...patch } : r)\n presetTouched = true\n }\n\n // ---- Filter evaluator -------------------------------------------------\n function evalRule(row: Row, rule: Rule): boolean {\n const field = FIELDS.find((f) => f.id === rule.field)!\n const raw = row[rule.field]\n switch (rule.operator) {\n case 'contains': return String(raw).toLowerCase().includes(rule.v1.toLowerCase())\n case 'equals': return String(raw) === rule.v1\n case 'notEquals': return String(raw) !== rule.v1\n case 'startsWith': return String(raw).toLowerCase().startsWith(rule.v1.toLowerCase())\n case 'inSet': return rule.set.length === 0 ? true : rule.set.includes(String(raw))\n case 'gt': return Number(raw) > Number(rule.v1)\n case 'lt': return Number(raw) < Number(rule.v1)\n case 'gte': return Number(raw) >= Number(rule.v1)\n case 'lte': return Number(raw) <= Number(rule.v1)\n case 'between': {\n const lo = Math.min(Number(rule.v1), Number(rule.v2))\n const hi = Math.max(Number(rule.v1), Number(rule.v2))\n return Number(raw) >= lo && Number(raw) <= hi\n }\n case 'before': return rule.v1 ? String(raw) < rule.v1 : true\n case 'after': return rule.v1 ? String(raw) > rule.v1 : true\n case 'within': {\n const n = Number(rule.v1)\n if (!Number.isFinite(n) || n <= 0) return true\n const now = new Date(); const future = new Date(); future.setDate(now.getDate() + n)\n const v = new Date(String(raw))\n return v >= now && v <= future\n }\n default: return true\n }\n }\n\n const filtered = $derived.by(() => {\n if (rules.length === 0) return allRows\n return allRows.filter((row) =>\n combinator === 'AND'\n ? rules.every((r) => evalRule(row, r))\n : rules.some((r) => evalRule(row, r))\n )\n })\n\n function clearAll() { rules = []; presetTouched = true }\n function preset_atRisk_EMEA() {\n combinator = 'AND'\n rules = [\n { id: crypto.randomUUID(), field: 'region', operator: 'equals', v1: 'EMEA', v2: '', set: [] },\n { id: crypto.randomUUID(), field: 'churnRisk', operator: 'inSet', v1: '', v2: '', set: ['medium', 'high'] },\n { id: crypto.randomUUID(), field: 'arr', operator: 'gt', v1: '50000', v2: '', set: [] },\n ]\n presetTouched = false\n }\n function preset_expiring() {\n combinator = 'AND'\n rules = [\n { id: crypto.randomUUID(), field: 'contractEnd', operator: 'within', v1: '90', v2: '', set: [] },\n { id: crypto.randomUUID(), field: 'healthScore', operator: 'lt', v1: '60', v2: '', set: [] },\n ]\n presetTouched = false\n }\n function preset_topAccounts() {\n combinator = 'OR'\n rules = [\n { id: crypto.randomUUID(), field: 'arr', operator: 'gt', v1: '300000', v2: '', set: [] },\n { id: crypto.randomUUID(), field: 'seats', operator: 'gt', v1: '150', v2: '', set: [] },\n ]\n presetTouched = false\n }\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n const columns: ColumnDef<typeof features, Row>[] = [\n { field: 'id', header: 'Account', width: 110, editable: false },\n { field: 'company', header: 'Company', width: 200, editable: false },\n { field: 'region', header: 'Region', width: 110, editable: false },\n { field: 'industry', header: 'Industry', width: 130, editable: false },\n { field: 'arr', header: 'ARR', width: 130, align: 'right', editable: false,\n format: { type: 'number', options: { style: 'currency', currency: 'USD', maximumFractionDigits: 0 } } },\n { field: 'seats', header: 'Seats', width: 90, align: 'right', editable: false },\n { field: 'churnRisk', header: 'Churn risk', width: 110, editable: false,\n cellClass: (ctx) => `risk-${ctx.getValue()}` },\n { field: 'contractEnd', header: 'Contract end', width: 130, editable: false },\n { field: 'healthScore', header: 'Health', width: 110, align: 'right', editable: false },\n ]\n\n const usd = (n: number) => n >= 1_000_000 ? `$${(n / 1_000_000).toFixed(2)}M`\n : n >= 1_000 ? `$${(n / 1_000).toFixed(0)}k`\n : `$${n.toFixed(0)}`\n const totalArr = $derived(filtered.reduce((s, r) => s + r.arr, 0))\n</script>\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <!-- Presets bar --------------------------------------------------- -->\n <div class=\"preset-bar shrink-0\">\n <span class=\"preset-label\">Quick views:</span>\n <button class=\"preset\" onclick={preset_atRisk_EMEA}>At-risk EMEA accounts</button>\n <button class=\"preset\" onclick={preset_expiring}>Expiring contracts (90 days)</button>\n <button class=\"preset\" onclick={preset_topAccounts}>Top accounts (ARR or seats)</button>\n {#if presetTouched && rules.length > 0}\n <span class=\"preset-touched\">● customised</span>\n {/if}\n <div class=\"preset-spacer\"></div>\n <div class=\"match-stat\">\n Matches: <strong>{filtered.length}</strong> / {allRows.length}\n <span class=\"match-stat-meta\">· ARR {usd(totalArr)}</span>\n </div>\n </div>\n\n <!-- Builder ------------------------------------------------------- -->\n <div class=\"builder shrink-0\">\n <div class=\"builder-head\">\n <div class=\"combinator\">\n <span class=\"builder-label\">Match</span>\n <button class={`combo-btn ${combinator === 'AND' ? 'is-on' : ''}`} onclick={() => combinator = 'AND'}>ALL (AND)</button>\n <button class={`combo-btn ${combinator === 'OR' ? 'is-on' : ''}`} onclick={() => combinator = 'OR'}>ANY (OR)</button>\n <span class=\"builder-label muted\">of the rules below</span>\n </div>\n <div class=\"builder-actions\">\n <button class=\"link-btn\" onclick={addRule}>+ Add rule</button>\n <button class=\"link-btn danger\" onclick={clearAll} disabled={rules.length === 0}>Clear all</button>\n </div>\n </div>\n\n {#if rules.length === 0}\n <div class=\"rules-empty\">No rules - showing all rows. Click \"+ Add rule\" or pick a preset.</div>\n {:else}\n <ul class=\"rules\">\n {#each rules as rule, i (rule.id)}\n {@const field = FIELDS.find((f) => f.id === rule.field)!}\n {@const opDef = OPERATORS.find((o) => o.id === rule.operator)!}\n <li class=\"rule\">\n <span class=\"rule-link\">{i === 0 ? 'WHERE' : combinator}</span>\n <select class=\"rule-field\" value={rule.field}\n onchange={(e) => onFieldChange(rule, (e.currentTarget as HTMLSelectElement).value as keyof Row)}>\n {#each FIELDS as f (f.id)}<option value={f.id}>{f.label}</option>{/each}\n </select>\n <select class=\"rule-op\" value={rule.operator}\n onchange={(e) => updateRule(rule.id, { operator: (e.currentTarget as HTMLSelectElement).value as OperatorId, v1: '', v2: '', set: [] })}>\n {#each operatorsFor(field.type) as o (o.id)}<option value={o.id}>{o.label}</option>{/each}\n </select>\n\n {#if opDef.nValues === 'set' && field.options}\n <span class=\"rule-set\">\n {#each field.options as opt (opt)}\n <label class=\"chip-check\">\n <input type=\"checkbox\" checked={rule.set.includes(opt)}\n onchange={(e) => updateRule(rule.id, {\n set: (e.currentTarget as HTMLInputElement).checked\n ? [...rule.set, opt]\n : rule.set.filter((x) => x !== opt)\n })} />\n <span>{opt}</span>\n </label>\n {/each}\n </span>\n {:else if field.type === 'enum' && field.options}\n <select class=\"rule-val\" value={rule.v1}\n onchange={(e) => updateRule(rule.id, { v1: (e.currentTarget as HTMLSelectElement).value })}>\n <option value=\"\">- choose -</option>\n {#each field.options as opt (opt)}<option value={opt}>{opt}</option>{/each}\n </select>\n {:else if field.type === 'number'}\n <input class=\"rule-val\" type=\"number\"\n value={rule.v1}\n oninput={(e) => updateRule(rule.id, { v1: (e.currentTarget as HTMLInputElement).value })} />\n {#if opDef.nValues === 2}\n <span class=\"rule-and\">and</span>\n <input class=\"rule-val\" type=\"number\"\n value={rule.v2}\n oninput={(e) => updateRule(rule.id, { v2: (e.currentTarget as HTMLInputElement).value })} />\n {/if}\n {:else if field.type === 'date'}\n {#if opDef.id === 'within'}\n <input class=\"rule-val\" type=\"number\" min=\"1\" max=\"365\"\n placeholder=\"N days\"\n value={rule.v1}\n oninput={(e) => updateRule(rule.id, { v1: (e.currentTarget as HTMLInputElement).value })} />\n {:else}\n <input class=\"rule-val\" type=\"date\"\n value={rule.v1}\n oninput={(e) => updateRule(rule.id, { v1: (e.currentTarget as HTMLInputElement).value })} />\n {/if}\n {:else}\n <input class=\"rule-val\" type=\"text\"\n value={rule.v1}\n oninput={(e) => updateRule(rule.id, { v1: (e.currentTarget as HTMLInputElement).value })} />\n {/if}\n\n <button class=\"rule-x\" aria-label=\"Remove rule\"\n onclick={() => removeRule(rule.id)}>×</button>\n </li>\n {/each}\n </ul>\n {/if}\n </div>\n\n <div class=\"flex-1 min-h-0\">\n <SvGrid responsive={true}\n data={filtered}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"cell\"\n showPagination={false}\n enableInlineEditing={false}\n enableCellSelection={true}\n enableRowSummaries={false}\n rowHeight={32}\n containerHeight=\"100%\"\n fitColumns={true}\n />\n </div>\n</section>\n\n<style>\n /* ---- Preset bar ---- */\n .preset-bar {\n display: flex; flex-wrap: wrap; align-items: center; gap: 8px;\n border: 1px solid var(--sg-border, #e2e8f0);\n background: var(--sg-bg, #fff);\n border-radius: 8px; padding: 8px 12px;\n }\n .preset-label, .builder-label {\n font-size: 11px; font-weight: 700; text-transform: uppercase;\n letter-spacing: 0.06em; color: var(--sg-muted, #64748b);\n }\n .builder-label.muted { font-weight: 500; color: var(--sg-muted, #94a3b8); text-transform: none; letter-spacing: 0; }\n .preset {\n background: var(--sg-bg, #fff);\n border: 1px solid var(--sg-border, #cbd5e1);\n color: var(--sg-fg, #0f172a);\n border-radius: 999px; padding: 4px 12px; font-size: 12px;\n font-weight: 600; cursor: pointer;\n }\n .preset:hover {\n background: color-mix(in oklab, var(--sg-accent, #6366f1) 8%, transparent);\n border-color: var(--sg-accent, #6366f1);\n }\n .preset-touched { font-size: 11px; color: #f59e0b; font-weight: 700; padding: 0 6px; }\n .preset-spacer { flex: 1; }\n .match-stat {\n font-size: 13px; color: var(--sg-fg, #0f172a);\n font-variant-numeric: tabular-nums;\n }\n .match-stat strong { color: var(--sg-accent, #6366f1); font-size: 16px; }\n .match-stat-meta { color: var(--sg-muted, #64748b); }\n\n /* ---- Builder ---- */\n .builder {\n border: 1px solid var(--sg-border, #e2e8f0);\n background: linear-gradient(180deg, color-mix(in oklab, var(--sg-accent, #6366f1) 4%, var(--sg-bg, #fff)), var(--sg-bg, #fff));\n border-radius: 10px; padding: 10px 12px;\n display: flex; flex-direction: column; gap: 6px;\n }\n .builder-head { display: flex; align-items: center; gap: 14px; }\n .combinator { display: flex; align-items: center; gap: 8px; flex: 1; }\n .combo-btn {\n background: var(--sg-bg, #fff);\n border: 1px solid var(--sg-border, #cbd5e1);\n border-radius: 6px; padding: 4px 10px; font-size: 12px;\n font-weight: 700; cursor: pointer;\n color: var(--sg-muted, #64748b);\n }\n .combo-btn.is-on {\n background: var(--sg-accent, #6366f1); color: var(--sg-on-accent, #fff);\n border-color: transparent;\n }\n .builder-actions { display: flex; gap: 6px; }\n .link-btn {\n background: transparent; border: 0; padding: 4px 8px;\n color: var(--sg-accent, #6366f1); font-size: 12px; font-weight: 700; cursor: pointer;\n border-radius: 4px;\n }\n .link-btn:hover { background: color-mix(in oklab, var(--sg-accent, #6366f1) 12%, transparent); }\n .link-btn.danger { color: #b91c1c; }\n .link-btn:disabled { opacity: 0.4; cursor: default; }\n .link-btn:disabled:hover { background: transparent; }\n\n .rules-empty {\n padding: 8px 4px; font-size: 12px; font-style: italic;\n color: var(--sg-muted, #94a3b8);\n }\n .rules { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }\n .rule {\n display: flex; flex-wrap: wrap; align-items: center; gap: 6px;\n background: var(--sg-bg, #fff);\n border: 1px solid var(--sg-border, #e2e8f0);\n border-radius: 6px; padding: 6px 8px;\n }\n .rule-link {\n font-size: 10px; font-weight: 800;\n color: var(--sg-accent, #6366f1);\n background: color-mix(in oklab, var(--sg-accent, #6366f1) 10%, transparent);\n border-radius: 4px; padding: 2px 6px;\n width: 56px; text-align: center;\n }\n .rule-field, .rule-op, .rule-val {\n border: 1px solid var(--sg-input-border, #cbd5e1);\n background: var(--sg-input-bg, #fff);\n color: var(--sg-fg, #0f172a);\n border-radius: 5px; padding: 4px 8px; font-size: 12.5px; font-family: inherit;\n }\n .rule-field { font-weight: 600; }\n .rule-val { min-width: 120px; }\n .rule-and { font-size: 11px; color: var(--sg-muted, #64748b); padding: 0 2px; }\n .rule-set { display: inline-flex; gap: 4px; flex-wrap: wrap; }\n .chip-check {\n display: inline-flex; align-items: center; gap: 4px;\n background: var(--sg-bg, #fff);\n border: 1px solid var(--sg-border, #cbd5e1);\n border-radius: 999px; padding: 2px 8px;\n font-size: 11px; cursor: pointer; user-select: none;\n }\n .chip-check input { accent-color: var(--sg-accent, #6366f1); }\n .chip-check:has(input:checked) {\n background: color-mix(in oklab, var(--sg-accent, #6366f1) 15%, var(--sg-bg, #fff));\n border-color: var(--sg-accent, #6366f1); color: var(--sg-accent, #4338ca); font-weight: 600;\n }\n .rule-x {\n margin-left: auto;\n background: transparent; border: 0; cursor: pointer;\n color: var(--sg-muted, #94a3b8); font-size: 16px; padding: 2px 6px;\n border-radius: 4px;\n }\n .rule-x:hover { background: #fee2e2; color: #b91c1c; }\n\n /* Risk colors */\n :global(td.risk-low) { color: #16a34a; font-weight: 600; }\n :global(td.risk-medium) { color: #d97706; font-weight: 600; }\n :global(td.risk-high) { color: #dc2626; font-weight: 700; }\n</style>\n"
2549
+ "blurb": "98. Advanced filter builder (visual query builder) A Notion / Linear style filter panel, built on SvGrid's OWN advanced filter rather than a bespoke one.",
2550
+ "source": "<script lang=\"ts\">\n /**\n * 98. Advanced filter builder (visual query builder)\n * --------------------------------------------------\n * A Notion / Linear style filter panel, built on SvGrid's OWN advanced\n * filter rather than a bespoke one.\n *\n * The grid keeps the whole dataset (`data={allRows}`) and filters itself:\n * `<SvAdvancedFilter>` writes a predicate expression through\n * `api.setAdvancedFilter()`, and the grid applies it after its global,\n * column and facet filters. That ordering matters - swapping `data` for a\n * pre-filtered array (the obvious shortcut, and what this demo used to do)\n * silently breaks the row count, the facet value lists, exports and saved\n * views, because the grid no longer knows what it is not showing you.\n *\n * A preset is just an expression, so \"saved view\" is one call. The last one\n * below is only expressible with the real engine: it compares each row to an\n * aggregate over the rows that survived the other filters.\n *\n * Requires `enableAdvancedFilter()` from @svgrid/enterprise.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n type ColumnDef,\n type GridPredicateExpr,\n type SvGridApi,\n } from '@svgrid/grid'\n import { SvAdvancedFilter, enableAdvancedFilter } from '@svgrid/enterprise'\n\n enableAdvancedFilter()\n\n type Row = {\n id: string\n company: string\n region: 'Americas' | 'EMEA' | 'APAC'\n industry: 'SaaS' | 'Manufacturing' | 'Retail' | 'Healthcare' | 'Finance'\n arr: number\n seats: number\n churnRisk: 'low' | 'medium' | 'high'\n contractEnd: string\n healthScore: number\n }\n\n // ---- Seed data ---------------------------------------------------------\n let prng = 0xDEC0DE\n function rand() { prng = (prng * 1664525 + 1013904223) >>> 0; return prng / 0xFFFFFFFF }\n function pick<T>(a: readonly T[]): T { return a[Math.floor(rand() * a.length)]! }\n function int(min: number, max: number) { return Math.floor(min + rand() * (max - min + 1)) }\n\n const REGIONS = ['Americas', 'EMEA', 'APAC'] as const\n const INDUSTRIES = ['SaaS', 'Manufacturing', 'Retail', 'Healthcare', 'Finance'] as const\n const RISKS = ['low', 'medium', 'high'] as const\n const COMPANY_NAMES = [\n 'Helios', 'Vertex', 'Atlas', 'Quantum', 'Stellar', 'Apex', 'Crescent', 'Sigma',\n 'Pioneer', 'Aurora', 'Granite', 'Cobalt', 'Meridian', 'Polaris', 'Sentinel', 'Tessera',\n 'Cascade', 'Beacon', 'Wavelength', 'Lumen', 'Echo', 'Cipher', 'Nimbus', 'Caldera',\n 'Sterling', 'Onyx', 'Slate', 'Ember', 'Verdant', 'Halcyon',\n ]\n const SUFFIXES = ['Labs', 'Group', 'Holdings', 'Industries', 'Systems', 'Capital', 'Partners', 'Networks']\n\n let allRows: Row[] = Array.from({ length: 250 }, (_, i) => {\n const yearOffset = int(0, 18) // contract ends 0-18 months from now\n const d = new Date(); d.setMonth(d.getMonth() + yearOffset)\n return {\n id: `ACC-${(1000 + i).toString()}`,\n company: `${pick(COMPANY_NAMES)} ${pick(SUFFIXES)}`,\n region: pick(REGIONS),\n industry: pick(INDUSTRIES),\n arr: int(5_000, 480_000),\n seats: int(3, 240),\n churnRisk: pick(RISKS),\n contractEnd:d.toISOString().slice(0, 10),\n healthScore: int(15, 99),\n }\n })\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n\n // `editorType` is what tells the filter UI which operators a column offers -\n // numeric ranges for ARR, date comparison for contractEnd, text for the rest.\n const columns: ColumnDef<typeof features, Row>[] = [\n { field: 'id', header: 'Account', width: 110, editable: false },\n { field: 'company', header: 'Company', width: 200, editable: false },\n { field: 'region', header: 'Region', width: 110, editable: false },\n { field: 'industry', header: 'Industry', width: 130, editable: false },\n { field: 'arr', header: 'ARR', width: 130, align: 'right', editable: false, editorType: 'number',\n format: { type: 'number', options: { style: 'currency', currency: 'USD', maximumFractionDigits: 0 } } },\n { field: 'seats', header: 'Seats', width: 90, align: 'right', editable: false, editorType: 'number' },\n { field: 'churnRisk', header: 'Churn risk', width: 110, editable: false,\n cellClass: (ctx) => `risk-${ctx.getValue()}` },\n { field: 'contractEnd', header: 'Contract end', width: 130, editable: false, editorType: 'date' },\n { field: 'healthScore', header: 'Health', width: 110, align: 'right', editable: false, editorType: 'number' },\n ]\n\n let api = $state<SvGridApi<typeof features, Row> | null>(null)\n let matches = $state(allRows.length)\n let activePreset = $state<string | null>(null)\n // Bound to the panel, so a preset drives what the panel SHOWS as well as what\n // the grid filters. Calling api.setAdvancedFilter() directly would filter the\n // grid while the panel kept displaying its own stale draft.\n let expression = $state<GridPredicateExpr | null>(null)\n\n function refreshStats() {\n matches = api?.getDisplayedRows().length ?? allRows.length\n }\n\n function applyPreset(name: string, expr: GridPredicateExpr) {\n activePreset = name\n expression = expr\n api?.setAdvancedFilter(expr)\n queueMicrotask(refreshStats)\n }\n\n function clearAll() {\n activePreset = null\n expression = null\n api?.clearAdvancedFilter()\n queueMicrotask(refreshStats)\n }\n\n // ---- Presets, written directly as predicate expressions ----------------\n const isoInDays = (n: number) => {\n const d = new Date()\n d.setDate(d.getDate() + n)\n return d.toISOString().slice(0, 10)\n }\n\n const PRESETS: Array<{ name: string; label: string; expr: GridPredicateExpr }> = [\n {\n name: 'at-risk',\n label: 'At-risk EMEA accounts',\n expr: {\n kind: 'and',\n parts: [\n { kind: 'cmp', column: 'region', op: 'equals', value: 'EMEA' },\n { kind: 'cmp', column: 'churnRisk', op: 'in', value: ['medium', 'high'] },\n { kind: 'cmp', column: 'arr', op: 'greaterThan', value: '50000' },\n ],\n },\n },\n {\n name: 'expiring',\n label: 'Expiring contracts (90 days)',\n // This demo used to carry a bespoke \"within N days\" operator. A date\n // range says the same thing with the standard set: ISO dates compare\n // lexicographically, so `between` orders them chronologically.\n expr: {\n kind: 'and',\n parts: [\n { kind: 'cmp', column: 'contractEnd', op: 'between', value: isoInDays(0), valueTo: isoInDays(90) },\n { kind: 'cmp', column: 'healthScore', op: 'lessThan', value: '60' },\n ],\n },\n },\n {\n name: 'top',\n label: 'Top accounts (ARR or seats)',\n expr: {\n kind: 'or',\n parts: [\n { kind: 'cmp', column: 'arr', op: 'greaterThan', value: '300000' },\n { kind: 'cmp', column: 'seats', op: 'greaterThan', value: '150' },\n ],\n },\n },\n {\n name: 'nested',\n label: 'EMEA, or big APAC at risk',\n // A group nested inside the top-level OR. The flat builder could not show\n // this - it fell back to text mode - so open the Builder tab on this one\n // to see the nested group with its own AND.\n expr: {\n kind: 'or',\n parts: [\n { kind: 'cmp', column: 'region', op: 'equals', value: 'EMEA' },\n {\n kind: 'and',\n parts: [\n { kind: 'cmp', column: 'region', op: 'equals', value: 'APAC' },\n { kind: 'cmp', column: 'arr', op: 'greaterThan', value: '250000' },\n { kind: 'cmp', column: 'churnRisk', op: 'equals', value: 'high' },\n ],\n },\n ],\n },\n },\n {\n name: 'above-average',\n label: 'ARR above the current average',\n // Not expressible as a column filter at all: it compares each row to an\n // aggregate over the rows still showing. The engine folds that average\n // once per filter change rather than once per row.\n expr: {\n kind: 'scalarCmp',\n left: { kind: 'col', id: 'arr' },\n op: '>',\n right: { kind: 'agg', fn: 'avg', column: 'arr' },\n },\n },\n ]\n</script>\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <!-- Presets bar --------------------------------------------------- -->\n <div class=\"preset-bar shrink-0\">\n <span class=\"preset-label\">Quick views:</span>\n {#each PRESETS as p (p.name)}\n <button\n class={`preset ${activePreset === p.name ? 'is-on' : ''}`}\n onclick={() => applyPreset(p.name, p.expr)}\n >{p.label}</button>\n {/each}\n <button class=\"preset\" onclick={clearAll}>Clear</button>\n <div class=\"preset-spacer\"></div>\n <div class=\"match-stat\">\n Matches: <strong>{matches}</strong> / {allRows.length}\n </div>\n </div>\n\n <!-- The grid's own filter panel ----------------------------------- -->\n {#if api}\n <div class=\"builder shrink-0\">\n <SvAdvancedFilter\n {api}\n bind:expression\n onApply={() => {\n activePreset = null\n queueMicrotask(refreshStats)\n }}\n />\n </div>\n {/if}\n\n <div class=\"flex-1 min-h-0\">\n <SvGrid\n data={allRows}\n {columns}\n {features}\n onApiReady={(a) => { api = a; refreshStats() }}\n onAdvancedFilterChange={(expr) => {\n // The grid can clear the filter itself, from the toolbar chip. Without\n // this the grid would unfilter while the panel kept showing \"Active\"\n // and this counter kept the old number.\n expression = expr\n if (expr == null) activePreset = null\n queueMicrotask(refreshStats)\n }}\n containerHeight=\"100%\"\n />\n </div>\n</section>\n\n<style>\n /* ---- Preset bar ---- */\n .preset-bar {\n display: flex; flex-wrap: wrap; align-items: center; gap: 8px;\n border: 1px solid var(--sg-border, #e2e8f0);\n background: var(--sg-bg, #fff);\n border-radius: 8px; padding: 8px 12px;\n }\n .preset-label {\n font-size: 11px; font-weight: 700; text-transform: uppercase;\n letter-spacing: 0.06em; color: var(--sg-muted, #64748b);\n }\n .preset {\n background: var(--sg-bg, #fff);\n border: 1px solid var(--sg-border, #cbd5e1);\n color: var(--sg-fg, #0f172a);\n border-radius: 999px; padding: 4px 12px; font-size: 12px;\n font-weight: 600; cursor: pointer;\n }\n .preset:hover {\n background: color-mix(in oklab, var(--sg-accent, #6366f1) 8%, transparent);\n border-color: var(--sg-accent, #6366f1);\n }\n .preset.is-on {\n background: var(--sg-accent, #6366f1);\n border-color: var(--sg-accent, #6366f1);\n color: var(--sg-on-accent, #fff);\n }\n .preset-spacer { flex: 1; }\n .match-stat {\n font-size: 13px; color: var(--sg-fg, #0f172a);\n font-variant-numeric: tabular-nums;\n }\n .match-stat strong { color: var(--sg-accent, #6366f1); font-size: 16px; }\n\n /* ---- Builder shell ---- */\n .builder {\n border: 1px solid var(--sg-border, #e2e8f0);\n background: linear-gradient(180deg, color-mix(in oklab, var(--sg-accent, #6366f1) 4%, var(--sg-bg, #fff)), var(--sg-bg, #fff));\n border-radius: 10px; padding: 10px 12px;\n }\n\n /* ---- Risk pills, set via cellClass ---- */\n :global(.risk-high) { color: #dc2626; font-weight: 600; }\n :global(.risk-medium) { color: #d97706; font-weight: 600; }\n :global(.risk-low) { color: #059669; }\n</style>\n"
2551
2551
  },
2552
2552
  {
2553
2553
  "id": "99-top-n-filter",
@@ -2654,25 +2654,37 @@ export const docs = [
2654
2654
  "slug": "compliance/index",
2655
2655
  "path": "docs/compliance/index.md",
2656
2656
  "title": "Compliance",
2657
- "markdown": "# Compliance\r\n\r\nsv-grid is a client-side UI library - **all data stays in the\r\nbrowser**, the library never makes a network call of its own, no\r\ntelemetry phones home. The compliance story is therefore short, but\r\nbecause enterprise procurement asks the same questions every time,\r\nthis section answers each one directly.\r\n\r\n> If your reviewer wants a one-pager: jump to the\r\n> [vendor-questionnaire shortlist](#vendor-questionnaire-shortlist)\r\n> at the bottom.\r\n\r\n## Pages\r\n\r\n- [SOC 2 posture](./soc2.md) - what the library covers, what your\r\n hosting / build pipeline must cover\r\n- [GDPR + data residency](./gdpr.md) - personal-data handling, where\r\n data physically sits, the user-rights surface\r\n- [HIPAA posture](./hipaa.md) - PHI handling in the browser, what\r\n \"no PHI on disk\" requires you to wire\r\n- [Audit log integration](./audit-log.md) - turn the grid's callbacks\r\n into an immutable audit trail with one adapter\r\n\r\n## Vendor-questionnaire shortlist\r\n\r\n| Question | Answer |\r\n| ------------------------------------------------- | --------------------------------------------------------------- |\r\n| Does the library transmit any data? | **No.** Zero outbound network calls. Inspect with DevTools. |\r\n| Does the library write to localStorage? | **Only when you opt in.** [Saved views](../help/saved-views.md) writes when you tell it to. |\r\n| Does the library evaluate user input as code? | **No.** CSP-compliant; no `eval` / `new Function`. |\r\n| Does the library include third-party trackers? | **No.** Verify the bundle - ~78 kB gzip, no analytics SDK. |\r\n| Is the library SOC 2 / ISO 27001 certified? | The LIBRARY can't be certified - it's not a service. Your hosted app gets certified; the library is in-scope as a dependency. See [SOC 2 posture](./soc2.md). |\r\n| Is the library GDPR-compliant? | The library is GDPR-neutral: it never processes data the user didn't already see. See [GDPR + data residency](./gdpr.md). |\r\n| Is the library HIPAA-compliant? | Same: HIPAA-neutral. PHI handling is a property of your app, not the grid. See [HIPAA posture](./hipaa.md). |\r\n| Is the source code auditable? | **Yes.** MIT-licensed; published as readable source (no minified obfuscation). |\r\n| Where is data stored? | **In your app's memory.** Never on a sv-grid server. There is no sv-grid server. |\r\n| Is there a security disclosure policy? | Yes - email `support@jqwidgets.com`. Patches typically ship within 7 days for high-severity issues. |\r\n| Is the library tested for accessibility? | Yes - WAI-ARIA 1.2 grid pattern + axe-core in CI. See [accessibility](../help/accessibility.md). |\r\n| Are dependencies vetted? | Yes - 0 runtime dependencies in `@svgrid/grid`. `@svgrid/enterprise` lazy-loads `jszip` + `pdfmake` as peers. See [security](../help/security.md) for the dep table. |\r\n| Is there an SBOM? | Yes - `pnpm run sbom` emits CycloneDX 1.5. See [security](../help/security.md#sbom-generation). |\r\n\r\n## See also\r\n\r\n- [Security & supply chain](../help/security.md) - the parent posture\r\n- [Observability](../help/observability.md) - the audit log seam\r\n- [API stability](../help/api-stability.md) - the deprecation promise\r\n"
2657
+ "markdown": "# Compliance\r\n\r\nsv-grid is a client-side UI library - **all data stays in the\r\nbrowser**, the library never makes a network call of its own, no\r\ntelemetry phones home. The compliance story is therefore short, but\r\nbecause enterprise procurement asks the same questions every time,\r\nthis section answers each one directly.\r\n\r\n> If your reviewer wants a one-pager: jump to the\r\n> [vendor-questionnaire shortlist](#vendor-questionnaire-shortlist)\r\n> at the bottom.\r\n\r\n## Pages\r\n\r\n- [SOC 2 posture](./soc2.md) - what the library covers, what your\r\n hosting / build pipeline must cover\r\n- [GDPR + data residency](./gdpr.md) - personal-data handling, where\r\n data physically sits, the user-rights surface\r\n- [HIPAA posture](./hipaa.md) - PHI handling in the browser, what\r\n \"no PHI on disk\" requires you to wire\r\n- [Accessibility Conformance Report (VPAT 2.5Rev)](./vpat.md) - the\r\n per-criterion WCAG 2.1 / Section 508 / EN 301 549 claim, with the\r\n evidence behind each one and the gaps stated rather than omitted.\r\n- [Audit log integration](./audit-log.md) - turn the grid's callbacks\r\n into an immutable audit trail with one adapter\r\n\r\n## Vendor-questionnaire shortlist\r\n\r\n| Question | Answer |\r\n| ------------------------------------------------- | --------------------------------------------------------------- |\r\n| Does the library transmit any data? | **No.** Zero outbound network calls. Inspect with DevTools. |\r\n| Does the library write to localStorage? | **Only when you opt in.** [Saved views](../help/saved-views.md) writes when you tell it to. |\r\n| Does the library evaluate user input as code? | **No.** CSP-compliant; no `eval` / `new Function`. |\r\n| Does the library include third-party trackers? | **No.** Verify the bundle - ~77 kB gzip, no analytics SDK. |\r\n| Is the library SOC 2 / ISO 27001 certified? | The LIBRARY can't be certified - it's not a service. Your hosted app gets certified; the library is in-scope as a dependency. See [SOC 2 posture](./soc2.md). |\r\n| Is the library GDPR-compliant? | The library is GDPR-neutral: it never processes data the user didn't already see. See [GDPR + data residency](./gdpr.md). |\r\n| Is the library HIPAA-compliant? | Same: HIPAA-neutral. PHI handling is a property of your app, not the grid. See [HIPAA posture](./hipaa.md). |\r\n| Is the source code auditable? | **Yes.** MIT-licensed; published as readable source (no minified obfuscation). |\r\n| Where is data stored? | **In your app's memory.** Never on a sv-grid server. There is no sv-grid server. |\r\n| Is there a security disclosure policy? | Yes - email `support@jqwidgets.com`. Patches typically ship within 7 days for high-severity issues. |\r\n| Is the library tested for accessibility? | **Yes.** `axe-core` runs against a rendered `<SvGrid>` in CI on every commit, across the plain grid, the filter row, row selection and pagination. Layout-dependent rules (notably colour contrast) are disabled because the suite runs in jsdom, which performs no layout; contrast is covered instead by a computed check over all 20 built-in themes in light and dark. See [accessibility](../help/accessibility.md). |\r\n| Do you publish a VPAT / ACR? | **Yes.** [VPAT 2.5Rev INT](./vpat.md), covering WCAG 2.1 AA, Revised Section 508 and EN 301 549 in one document. It is a self-assessment, and says so: every \"Supports\" names the test behind it, and the two known gaps (no `aria-invalid` on cell editors, no recorded screen-reader test pass) are stated in the report rather than left out. |\r\n| Are dependencies vetted? | Yes - 0 runtime dependencies in `@svgrid/grid`. `@svgrid/enterprise` lazy-loads `jszip` + `pdfmake` as peers. See [security](../help/security.md) for the dep table. |\r\n| Is there an SBOM? | **Yes.** A CycloneDX 1.6 document per published package lives in [`sbom/`](https://github.com/sv-grid/sv-grid/tree/main/sbom), regenerated with `pnpm sbom`. With 0 runtime dependencies in the grid the graph is shallow by construction. See [security](../help/security.md#sbom). |\r\n\r\n## See also\r\n\r\n- [Security & supply chain](../help/security.md) - the parent posture\r\n- [Observability](../help/observability.md) - the audit log seam\r\n- [API stability](../help/api-stability.md) - the deprecation promise\r\n"
2658
2658
  },
2659
2659
  {
2660
2660
  "slug": "compliance/soc2",
2661
2661
  "path": "docs/compliance/soc2.md",
2662
2662
  "title": "SOC 2 posture",
2663
- "markdown": "# SOC 2 posture\n\nsv-grid is a UI library, not a service - so it cannot itself hold a\nSOC 2 report. But it can sit inside a SOC 2-audited application, and\nthe controls below describe the line where the library's\nresponsibility ends and yours begins.\n\n## TL;DR for procurement\n\n> sv-grid is a client-side JavaScript library shipped as MIT-licensed\n> source. It performs no network IO, holds no user data, and has no\n> backend. SOC 2 audit scope applies to your hosting and build\n> pipeline, not the library. We give you the inputs (SBOM, security\n> disclosure policy, deterministic builds) you need to include\n> sv-grid in your own SOC 2 report.\n\n## What the library guarantees\n\n| SOC 2 control area | Library covers |\n| --------------------- | --------------------------------------------------------------- |\n| **CC6.1 Logical access** | n/a - no service to log into |\n| **CC6.6 Encryption in transit** | n/a - no transit |\n| **CC7.1 System operations** | Bundle is deterministic; SHA-256 published per release |\n| **CC8.1 Change management** | Every change ships as a PR with reviews; release notes per version |\n| **CC9.2 Vendor management** | 0 runtime deps in `@svgrid/grid`; lazy peer deps in `@svgrid/enterprise` documented in [security](../help/security.md) |\n\n## What you cover (in your own SOC 2)\n\n| Control area | Your responsibility |\n| --------------------- | --------------------------------------------------------------- |\n| Hosting | Wherever your app is served from (Vercel / Cloudflare / your own infra) |\n| User authentication | Your app's auth layer - sv-grid never sees credentials |\n| Data at rest | Wherever your data sits BEFORE it reaches the grid |\n| Audit logging | Wire `onCellValueChange` etc. to your audit pipeline - see [audit log](./audit-log.md) |\n| Backup / restore | Your DB - the grid is stateless |\n| Incident response | Your SRE process |\n\n## Inputs we provide to your auditor\n\n1. **MIT licence** - vetted by your legal team once, valid forever\n2. **Public source code** - no obfuscation; your auditor can read every line\n3. **Published SBOM** - CycloneDX 1.5 generated per release; tracks every direct + transitive dep\n4. **Security disclosure policy** - email `support@jqwidgets.com`, GPG fingerprint published, response SLA documented in [security](../help/security.md)\n5. **Vulnerability history** - every CVE attributed to sv-grid published in the [changelog](../changelog.md) with disclosure date, fix version, mitigation\n6. **Deterministic builds** - reproducible `dist/` from a clean clone; the SHA-256 of each release artefact is published in the GitHub release notes\n7. **Code-review evidence** - every commit signed; every PR requires review from a CODEOWNER\n8. **Dependency review** - Renovate bot opens a PR within 24h of any upstream release; we review and tag\n\n## Common auditor questions\n\n> *\"Is there a SOC 2 report for sv-grid?\"*\n\nNo - it would be meaningless. There's no service. The library is a\ndependency of your application, the same way React or Svelte is. Your\nauditor will treat it as a dependency, in scope under CC9.2.\n\n> *\"Does sv-grid have access to our data?\"*\n\nNo. The library runs in the user's browser. Your data is whatever\nyour app hands to the `<SvGrid data={...}>` prop. We never see it.\n\n> *\"Can we self-host the docs?\"*\n\nYes - the entire `docs/` folder is in the repo. Clone, build, host\nbehind your VPN if your compliance regime requires it. The\n[MCP server](../help/mcp-server.md) runs locally too.\n\n> *\"What happens if a CVE is found in sv-grid?\"*\n\nTriage within 24h. High-severity patches typically ship within 7\ndays. Subscribers to the GitHub release notifications get the\nrelease tag the moment we cut it. We backport security fixes to the\nlast 2 minor versions; see [api-stability](../help/api-stability.md)\nfor the support window.\n\n## See also\n\n- [GDPR + data residency](./gdpr.md)\n- [Security & supply chain](../help/security.md) - SBOM, signing, dep table\n- [Audit log integration](./audit-log.md) - turn callbacks into audit events\n"
2663
+ "markdown": "# SOC 2 posture\r\n\r\nsv-grid is a UI library, not a service - so it cannot itself hold a\r\nSOC 2 report. But it can sit inside a SOC 2-audited application, and\r\nthe controls below describe the line where the library's\r\nresponsibility ends and yours begins.\r\n\r\n## TL;DR for procurement\r\n\r\n> sv-grid is a client-side JavaScript library shipped as MIT-licensed\r\n> source. It performs no network IO, holds no user data, and has no\r\n> backend. SOC 2 audit scope applies to your hosting and build\r\n> pipeline, not the library. We give you the inputs (SBOM, security\r\n> disclosure policy, deterministic builds) you need to include\r\n> sv-grid in your own SOC 2 report.\r\n\r\n## What the library guarantees\r\n\r\n| SOC 2 control area | Library covers |\r\n| --------------------- | --------------------------------------------------------------- |\r\n| **CC6.1 Logical access** | n/a - no service to log into |\r\n| **CC6.6 Encryption in transit** | n/a - no transit |\r\n| **CC7.1 System operations** | Bundle is deterministic; SHA-256 published per release |\r\n| **CC8.1 Change management** | Every change ships as a PR with reviews; release notes per version |\r\n| **CC9.2 Vendor management** | 0 runtime deps in `@svgrid/grid`; lazy peer deps in `@svgrid/enterprise` documented in [security](../help/security.md) |\r\n\r\n## What you cover (in your own SOC 2)\r\n\r\n| Control area | Your responsibility |\r\n| --------------------- | --------------------------------------------------------------- |\r\n| Hosting | Wherever your app is served from (Vercel / Cloudflare / your own infra) |\r\n| User authentication | Your app's auth layer - sv-grid never sees credentials |\r\n| Data at rest | Wherever your data sits BEFORE it reaches the grid |\r\n| Audit logging | Wire `onCellValueChange` etc. to your audit pipeline - see [audit log](./audit-log.md) |\r\n| Backup / restore | Your DB - the grid is stateless |\r\n| Incident response | Your SRE process |\r\n\r\n## Inputs we provide to your auditor\r\n\r\n1. **MIT licence** - vetted by your legal team once, valid forever\r\n2. **Public source code** - no obfuscation; your auditor can read every line\r\n3. **Zero runtime dependencies** - `@svgrid/grid`, `@svgrid/enterprise`, `@svgrid/grid-wc` and `@svgrid/ui` each declare no runtime dependencies, so there is no transitive tree to review. Verify with `npm view @svgrid/grid dependencies`. The two optional peers for Enterprise export (`jszip`, `pdfmake`, both MIT) are listed in [security](../help/security.md)\r\n4. **Published SBOM** - a CycloneDX 1.6 document per package under [`sbom/`](https://github.com/sv-grid/sv-grid/tree/main/sbom), covering runtime and peer dependencies to full declared depth. Regenerate with `pnpm sbom`; `pnpm sbom:check` fails if it has drifted from the manifests. Satisfies the EU Cyber Resilience Act's machine-readable SBOM expectation\r\n5. **Security disclosure policy** - email `support@jqwidgets.com`, GPG fingerprint published, response SLA documented in [security](../help/security.md)\r\n6. **Vulnerability history** - every CVE attributed to sv-grid published in the [changelog](../changelog.md) with disclosure date, fix version, mitigation\r\n7. **npm provenance** - `@svgrid/grid` is published from CI with `--provenance`, so npm records a verifiable link from the tarball back to the building workflow and commit\r\n\r\n## Common auditor questions\r\n\r\n> *\"Is there a SOC 2 report for sv-grid?\"*\r\n\r\nNo - it would be meaningless. There's no service. The library is a\r\ndependency of your application, the same way React or Svelte is. Your\r\nauditor will treat it as a dependency, in scope under CC9.2.\r\n\r\n> *\"Does sv-grid have access to our data?\"*\r\n\r\nNo. The library runs in the user's browser. Your data is whatever\r\nyour app hands to the `<SvGrid data={...}>` prop. We never see it.\r\n\r\n> *\"Can we self-host the docs?\"*\r\n\r\nYes - the entire `docs/` folder is in the repo. Clone, build, host\r\nbehind your VPN if your compliance regime requires it. The\r\n[MCP server](../help/mcp-server.md) runs locally too.\r\n\r\n> *\"What happens if a CVE is found in sv-grid?\"*\r\n\r\nTriage within 24h. High-severity patches typically ship within 7\r\ndays. Subscribers to the GitHub release notifications get the\r\nrelease tag the moment we cut it. We backport security fixes to the\r\nlast 2 minor versions; see [api-stability](../help/api-stability.md)\r\nfor the support window.\r\n\r\n## See also\r\n\r\n- [GDPR + data residency](./gdpr.md)\r\n- [Security & supply chain](../help/security.md) - SBOM, signing, dep table\r\n- [Audit log integration](./audit-log.md) - turn callbacks into audit events\r\n"
2664
+ },
2665
+ {
2666
+ "slug": "compliance/vpat",
2667
+ "path": "docs/compliance/vpat.md",
2668
+ "title": "Accessibility Conformance Report (VPAT 2.5Rev, INT)",
2669
+ "markdown": "# Accessibility Conformance Report (VPAT 2.5Rev, INT)\n\n**Product:** SvGrid (`@svgrid/grid`, `@svgrid/enterprise`)\n**Report version:** 1.0\n**Report date:** 2026-08-24\n**Contact:** support@jqwidgets.com\n\nThis report follows the **VPAT 2.5Rev International edition**, which covers the\nRevised Section 508 standards, EN 301 549, and WCAG 2.1 in one document.\n\n> **This is a self-assessment, not a third-party certification.** It records what\n> we test, how we test it, and what we have not tested. Nothing here is a legal\n> certification of conformance. Where a criterion depends on your content or your\n> theme, this report says so rather than claiming credit for it.\n\n## What SvGrid is, for the purposes of this report\n\nSvGrid is a **software component**, not a web page or an application. It renders\na data grid inside a page you control. Several WCAG criteria are therefore\nscoped to the page rather than the component (page title, language of page,\nbypass blocks, consistent navigation) and are marked **Not Applicable** with a\nnote; you remain responsible for them in the page that hosts the grid.\n\nSeveral others are **shared**: the grid supplies correct roles, names and\nkeyboard behaviour, but you supply cell content. A custom cell renderer that\nemits an image without alt text will fail 1.1.1 no matter what the grid does.\nThose are marked *Supports, with author responsibility* and the boundary is\nstated in the remarks.\n\n## Evaluation methods\n\n| Method | What it covers | Where it runs |\n| --- | --- | --- |\n| `axe-core` against a rendered grid | Roles, names, relationships, duplicate ids, nested-interactive violations. Four configurations: plain, filter row + global filter, row selection, pagination | `packages/grid/src/a11y.axe.test.ts`, on every commit in CI |\n| WCAG contrast computation | Text, secondary text, header text, text on zebra / hovered / selected rows, text on accent controls, and the accent as a focus indicator, for **all 20 built-in themes in both light and dark** | `packages/grid/src/themes/contrast.test.ts`, on every commit in CI |\n| ARIA contract unit tests | The role / `aria-*` property builders and the roving-tabindex pattern | `a11y.test.ts`, `a11y.contract.test.ts` |\n| Focus and live-region unit tests | Focus trap, dismissable layers, scroll lock, the `aria-live` announcer | `packages/grid/src/a11y/*.test.ts` |\n| Manual keyboard review | The documented keyboard map | Manual, not automated |\n\n### Known limits of this evidence\n\nStated plainly, because a conformance report that hides its gaps is worse than\nno report:\n\n- **The axe suite runs in jsdom**, which performs no layout or painting. Its own\n `color-contrast` rule is therefore disabled there, and geometry-dependent\n rules (`target-size`, `scrollable-region-focusable`) are not exercised.\n Contrast is covered separately and more thoroughly by computation over the\n theme tokens, but **target size has not been machine-verified**.\n- **No formal screen-reader test pass has been recorded.** The grid implements\n the WAI-ARIA 1.2 grid pattern and is audited structurally, but we do not\n currently publish results from a scripted NVDA / JAWS / VoiceOver run.\n Criteria that depend on assistive-technology behaviour are marked\n *Supports* on the strength of the ARIA implementation and automated audit,\n and that basis is noted per row.\n- **Only the built-in themes are contrast-tested.** A custom theme is yours to\n verify; the method is documented in [accessibility](../help/accessibility.md).\n\nWriting this report was itself an audit, and it found two real defects rather\nthan only describing existing behaviour. Both are fixed and covered by tests:\nthe grid never called its own `announce()`, so the documented status messages\nwere not being made at all; and a cell failing `validate` was marked with a red\nclass and a mouse-only tooltip, giving a screen-reader user no way to know the\nvalue had been rejected.\n\n## WCAG 2.1 Level A\n\n| Criterion | Conformance | Remarks |\n| --- | --- | --- |\n| 1.1.1 Non-text Content | Supports, with author responsibility | Grid chrome (sort, filter, menu, pagination controls) carries text or `aria-label`. Content inside cells is yours; a custom renderer must supply its own text alternatives. |\n| 1.2.x Time-based Media | Not Applicable | The grid renders no audio or video. |\n| 1.3.1 Info and Relationships | Supports | Native `<table>` semantics plus the ARIA grid pattern: `grid`, `rowgroup`, `row`, `columnheader`, `gridcell`, with `aria-rowindex` / `aria-colindex` / `aria-rowcount` / `aria-colcount`. Verified by the axe suite and the ARIA contract tests. |\n| 1.3.2 Meaningful Sequence | Supports | DOM order follows visual order, including with pinned columns. |\n| 1.3.3 Sensory Characteristics | Supports | Sort state is exposed via `aria-sort`, not by icon alone. |\n| 1.4.1 Use of Color | Supports, with author responsibility | Grid state is conveyed by ARIA as well as colour: sort by `aria-sort`, selection by `aria-selected`, focus by the roving tabindex, and a failed `validate` by `aria-invalid` plus its message rather than the red highlight alone. Conditional formatting you configure is yours to make non-colour-dependent. |\n| 1.4.2 Audio Control | Not Applicable | No audio. |\n| 2.1.1 Keyboard | Supports | Full keyboard operation: navigation, selection, sorting, filtering, editing, undo. Column resize handles are keyboard-operable with arrow keys. See the keyboard map in [accessibility](../help/accessibility.md). |\n| 2.1.2 No Keyboard Trap | Supports | Roving tabindex: one cell is tabbable and Tab exits the grid. Popovers (filter menus, editors) use a dismissable-layer stack that restores focus on close. Covered by the focus-trap unit tests. |\n| 2.1.4 Character Key Shortcuts | Supports | Single-character shortcuts act only while focus is inside the grid, and are suppressed while a cell editor is open. |\n| 2.2.1 Timing Adjustable | Not Applicable | No time limits. |\n| 2.2.2 Pause, Stop, Hide | Supports | No auto-updating content originates in the grid. Scroll animation and chevron transitions honour `prefers-reduced-motion: reduce`. |\n| 2.3.1 Three Flashes | Supports | Cell-flash highlighting on value change is a single fade well under three flashes per second. |\n| 2.4.1 Bypass Blocks | Not Applicable | Page-level concern. |\n| 2.4.2 Page Titled | Not Applicable | Page-level concern. |\n| 2.4.3 Focus Order | Supports | Roving tabindex keeps a single predictable stop; popovers return focus to their trigger. |\n| 2.4.4 Link Purpose | Supports, with author responsibility | Links rendered inside cells are yours. |\n| 2.5.1 Pointer Gestures | Supports | No multipoint or path-based gesture is required; drag operations (column reorder, resize, row drag) all have keyboard equivalents. |\n| 2.5.2 Pointer Cancellation | Supports | Actions fire on pointer-up. |\n| 2.5.3 Label in Name | Supports | Accessible names for grid controls begin with their visible text. |\n| 2.5.4 Motion Actuation | Not Applicable | No motion actuation. |\n| 3.1.1 Language of Page | Not Applicable | Page-level concern. |\n| 3.2.1 On Focus | Supports | Focus alone never changes context. |\n| 3.2.2 On Input | Supports | Filtering and editing update the grid in place; no unexpected context change. |\n| 3.3.1 Error Identification | Supports, with author responsibility | A cell failing a column's `validate` hook carries `aria-invalid=\"true\"`, and the message it returns reaches assistive technology as the cell's accessible description or as visually-hidden text read with the cell - not only as the hover tooltip. Deciding *what* is invalid is yours. |\n| 3.3.2 Labels or Instructions | Supports | Editors and filter inputs derive an accessible name from the column header. |\n| 4.1.2 Name, Role, Value | Supports | The core of the grid pattern, and the criterion the axe suite exercises most directly. Interactive chrome exposes role, name and state. |\n| 4.1.3 Status Messages | Supports | The status changes that move no focus are announced through a visually-hidden `aria-live=\"polite\"` region: filter match counts, filter clearing, and bulk selection changes. Changes the accessibility tree already carries (the focused cell, `aria-sort`, a single row's `aria-selected`) are deliberately not repeated there. Covered by `a11y.announce.test.ts` against a rendered grid. |\n\n## WCAG 2.1 Level AA\n\n| Criterion | Conformance | Remarks |\n| --- | --- | --- |\n| 1.4.3 Contrast (Minimum) | Supports for built-in themes; author responsibility for custom themes | Every one of the 20 built-in presets is CI-tested in both light and dark against 4.5:1 for body, secondary, header, zebra, hover, selection and accent-control text. A custom theme is not covered by that test. |\n| 1.4.4 Resize Text | Supports | Layout is token-driven and reflows at 200% zoom. |\n| 1.4.5 Images of Text | Supports | No images of text. |\n| 1.4.10 Reflow | Partially Supports | The grid reflows and its own regions scroll rather than the page. A data table with many columns still requires horizontal scrolling at 320 CSS pixels, which is inherent to tabular data; the `responsive` prop and per-column `hideBelow` let you reduce the column set at narrow widths. |\n| 1.4.11 Non-text Contrast | Supports | The accent used for focus and selection indication is CI-tested at 3:1 against the background for every theme. Note that decorative table gridlines are intentionally not held to 3:1: the grid's structure is conveyed by the accessibility tree, so the rule does not apply to them. |\n| 1.4.12 Text Spacing | Supports | Row height and cell padding are token-driven; no clipping under the required spacing overrides. |\n| 1.4.13 Content on Hover or Focus | Supports | Tooltips and popovers are dismissable with Escape, hoverable, and persist until dismissed. |\n| 2.4.5 Multiple Ways | Not Applicable | Page-level concern. |\n| 2.4.6 Headings and Labels | Supports | Column headers are descriptive `columnheader` elements. |\n| 2.4.7 Focus Visible | Supports | The active cell carries a visible focus ring that uses `currentColor`, so it survives forced-colors mode. |\n| 3.1.2 Language of Parts | Not Applicable | Page-level concern. |\n| 3.2.3 Consistent Navigation | Not Applicable | Page-level concern. |\n| 3.2.4 Consistent Identification | Supports | Grid controls are identified consistently across instances. |\n| 3.3.3 Error Suggestion | Supports, with author responsibility | The string your `validate` hook returns is carried through verbatim, so a correction hint (\"Score must be at least 90\") reaches the user. Writing a useful hint is yours. |\n| 3.3.4 Error Prevention | Not Applicable | No legal, financial or data-deletion transaction originates in the grid. |\n| 4.1.1 Parsing | Supports | Obsolete in WCAG 2.2; no duplicate ids or malformed markup, checked by the axe suite. |\n\n## Revised Section 508\n\nSection 508 incorporates **WCAG 2.0 Level A and AA** by reference. Every WCAG\n2.0 criterion is a subset of the 2.1 tables above, so the conformance claims\ncarry over unchanged.\n\n| Chapter | Conformance | Remarks |\n| --- | --- | --- |\n| 302 Functional Performance Criteria | Supports, with the limits noted above | Operation without vision relies on the ARIA grid pattern; see the screen-reader caveat in *Known limits*. |\n| 501-504 Software | Supports | The grid is authored content within a host application; it exposes platform accessibility services via ARIA in the browser. |\n| 602 Support Documentation | Supports | Accessibility documentation is published at [accessibility](../help/accessibility.md), including the keyboard map and verification guidance. |\n\n## EN 301 549\n\nEN 301 549 v3.2.1, the harmonised European standard, incorporates **WCAG 2.1\nLevel A and AA** for web content. Clauses 9.1 through 9.4 map directly onto the\nWCAG 2.1 tables above; clause 11 (software) is addressed by the same ARIA\nimplementation. No separate claims are made here.\n\n## Reproducing these results\n\n```bash\npnpm --filter @svgrid/grid test:lib # includes the axe and contrast suites\n```\n\nBoth suites fail the build on a regression, so this report is checked by CI\nrather than being a point-in-time snapshot.\n\n## See also\n\n- [Accessibility](../help/accessibility.md) - roles, keyboard map, and how to verify a custom theme\n- [Compliance overview](./index.md) - the wider procurement questions\n"
2664
2670
  },
2665
2671
  {
2666
2672
  "slug": "enterprise/README",
2667
2673
  "path": "docs/enterprise/README.md",
2668
2674
  "title": "Enterprise feature pack",
2669
- "markdown": "# Enterprise feature pack\r\n\r\n`@svgrid/enterprise` is a paid add-on for `@svgrid/grid`. It bolts onto\r\nthe same `<SvGrid>` you already have and adds three feature areas: data\r\nexport, data import, and pivot tables. (The AI helpers are built in and\r\n**free** in `@svgrid/grid` - see [AI assistant](../help/ai.md).)\r\n\r\n![The @svgrid/enterprise pack bolts data export, data import, and pivot tables onto the same SvGrid you already have.](/docs-media/enterprise-pack.svg)\r\n\r\n```bash\r\npnpm add @svgrid/enterprise\r\n```\r\n\r\n```ts\r\nimport { installEnterprise, setLicenseKey } from '@svgrid/enterprise'\r\n\r\nsetLicenseKey('SVENTERPRISE-…') // once, at app startup\r\nconst pro = installEnterprise(api) // wraps a SvGridApi with Enterprise methods\r\n```\r\n\r\nThat's the whole integration. Every Enterprise helper hangs off the api object\r\nyou already have; the grid component stays Community.\r\n\r\n## What ships in Enterprise\r\n\r\n### [Data export](../help/export.md)\r\n\r\n`pro.exportData(opts)` writes the current view to Excel (xlsx), PDF,\r\nCSV, TSV, or HTML. The xlsx writer honours:\r\n\r\n- **Cell + row styles** via `opts.styles` - read the same `--sg-*`\r\n tokens the grid renders with so the file matches the theme.\r\n- **Page header + footer** lines with text or embedded images\r\n (`opts.header`, `opts.footer`).\r\n- **Embedded images** from cell values when columns are listed in\r\n `opts.imageFields`.\r\n- **Multiple sheets** in one workbook via `opts.sheets`.\r\n- **Printable view** via `pro.print(opts)` - opens a new window with\r\n repeat-on-page headers, cover page, page-size + orientation.\r\n\r\nDemos: [56 theme-matched](../../examples/src/demos/56-export-theme-matched.svelte),\r\n[57 header + footer + logo](../../examples/src/demos/57-export-header-footer-logo.svelte),\r\n[58 images](../../examples/src/demos/58-export-with-images.svelte),\r\n[59 multi-sheet](../../examples/src/demos/59-export-multi-sheet.svelte).\r\n\r\n### [Data import](../help/import.md)\r\n\r\n`pro.importData(opts)` reads an Excel / CSV / TSV / JSON file (or\r\ninline text), maps columns to your row shape, validates each row, and\r\nreturns a typed `ImportResult` with `rows`, `errors`, and a `summary`.\r\nYou decide whether to commit the rows into the grid or preview first.\r\n\r\nDemo: [53 Excel import](../../examples/src/demos/53-excel-import.svelte).\r\n\r\n### AI assistant - now built-in + free\r\n\r\nThe natural-language filter, smart-fill, summarise, classify, chart-this,\r\nfind-anomalies and natural-language-export helpers moved into the free\r\n`@svgrid/grid` package. Import them from `@svgrid/grid` and register a provider\r\nwith `setAIProvider(yourAdapter)` (no model client is bundled - you bring\r\nOpenAI / Anthropic / Ollama / local). See [AI assistant](../help/ai.md). The\r\nonly enterprise touch-point is `aiExport`'s write step, which uses this pack's\r\nexport engine for xlsx / pdf.\r\n\r\nDemo: [51 AI assistant](../../examples/src/demos/51-ai-assistant.svelte).\r\n\r\n### [Pivot tables](../help/pivot.md)\r\n\r\n`createPivotModel(data, config)` (or `pro.pivot.build(config)` against\r\nthe live api) returns `{ rows, columns }` you feed to a separate\r\n`<SvGrid>` instance. Supports row + column axes, eight built-in\r\naggregators (sum/avg/min/max/count/countDistinct/first/last) or\r\ncustom, grand-total row + column, subtotals, custom axis sort.\r\n\r\nDemo: [52 pivot table + designer](../../examples/src/demos/52-pivot-table.svelte).\r\n\r\n## Licensing\r\n\r\nThe pack is **soft-gated**. Until a valid license key is set,\r\neverything still functions but the grid shows a small \"unlicensed\"\r\nwatermark + a one-time console nudge. Set a key once at app startup\r\nand the watermark disappears:\r\n\r\n```ts\r\nimport { setLicenseKey } from '@svgrid/enterprise'\r\nsetLicenseKey('SVENTERPRISE-XXXX-XXXX-XXXX')\r\n```\r\n\r\nFor dev builds, the demos use `setLicenseKey('SVENTERPRISE-DEV-DEMO')` to\r\nsuppress the watermark in screenshots.\r\n\r\nPricing + multi-app licensing: <https://svgrid.com/pricing/>.\r\n\r\n## How Enterprise integrates\r\n\r\n```ts\r\nimport { SvGrid, type SvGridApi } from '@svgrid/grid'\r\nimport { installEnterprise, setLicenseKey, type EnterpriseGridApi } from '@svgrid/enterprise'\r\n\r\nsetLicenseKey(import.meta.env.VITE_SVPRO_KEY)\r\n\r\nlet api = $state<EnterpriseGridApi<typeof features, Order> | null>(null)\r\n```\r\n\r\n```svelte\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n onApiReady={(next: SvGridApi<typeof features, Order>) => {\r\n api = installEnterprise(next)\r\n }}\r\n/>\r\n\r\n<button onclick={() => api?.exportData({ format: 'xlsx', filename: 'orders' })}>\r\n Export to Excel\r\n</button>\r\n```\r\n\r\n`installEnterprise` mutates and returns the same api object - existing\r\nreferences keep working. The `EnterpriseGridApi` type extends `SvGridApi` so\r\nthe grid's existing methods are still there alongside the new ones.\r\n\r\n## Tree-shaking\r\n\r\nEach Enterprise module is a separate subpath export:\r\n\r\n```ts\r\nimport { exportGrid } from '@svgrid/enterprise/export' // export only\r\nimport { importData } from '@svgrid/enterprise/import' // import only\r\nimport { createPivotModel } from '@svgrid/enterprise/pivot' // pivot only\r\n// AI helpers are free + built in: import { aiFilter } from '@svgrid/grid'\r\n```\r\n\r\nIf you only need export, the AI provider plumbing, pivot engine, and\r\nimport parser don't ship. The `@svgrid/enterprise` barrel import is\r\nconvenient; the subpaths are smaller.\r\n\r\n## Peer dependencies\r\n\r\n| Feature | Peer dep | Optional? |\r\n| ------- | -------------------- | ------------------------------------------ |\r\n| xlsx | `jszip` | Yes - only loaded when xlsx is exported. |\r\n| pdf | `pdfmake` | Yes - only loaded when pdf is exported. |\r\n| import | `jszip` (xlsx only) | Yes - only for xlsx import. |\r\n| AI | - | -. You bring your own provider client. |\r\n| pivot | - | -. Pure TypeScript, no runtime deps. |\r\n\r\nInstall only what you need:\r\n\r\n```bash\r\npnpm add @svgrid/enterprise jszip # community + Enterprise + xlsx\r\npnpm add @svgrid/enterprise jszip pdfmake # also pdf\r\n```\r\n\r\n## See also\r\n\r\n- [Getting started](../getting-started.md) - if you haven't seen the\r\n Community walkthrough yet.\r\n- [Help index](../help/index.md) - all topic pages including the four\r\n Enterprise pages.\r\n- [Missing features](../help/missing-features.md) - the honest gap list.\r\n"
2675
+ "markdown": "# Enterprise feature pack\r\n\r\n`@svgrid/enterprise` is a paid add-on for `@svgrid/grid`. It bolts onto\r\nthe same `<SvGrid>` you already have and adds three feature areas: data\r\nexport, data import, and pivot tables. (The AI helpers are built in and\r\n**free** in `@svgrid/grid` - see [AI assistant](../help/ai.md).)\r\n\r\n![The @svgrid/enterprise pack bolts data export, data import, and pivot tables onto the same SvGrid you already have.](/docs-media/enterprise-pack.svg)\r\n\r\n```bash\r\npnpm add @svgrid/enterprise\r\n```\r\n\r\n```ts\r\nimport { installEnterprise, setLicenseKey } from '@svgrid/enterprise'\r\n\r\nsetLicenseKey('SVENTERPRISE-…') // once, at app startup\r\nconst pro = installEnterprise(api) // wraps a SvGridApi with Enterprise methods\r\n```\r\n\r\nThat's the whole integration. Every Enterprise helper hangs off the api object\r\nyou already have; the grid component stays Community.\r\n\r\n## What ships in Enterprise\r\n\r\n### [Data export](../help/export.md)\r\n\r\n`pro.exportData(opts)` writes the current view to Excel (xlsx), PDF,\r\nCSV, TSV, or HTML from a single call.\r\n\r\n**CSV, TSV and JSON export do not need Enterprise** - they are free in\r\n`@svgrid/grid`, along with copy-to-clipboard. What Enterprise adds is\r\nxlsx, PDF, styled HTML and XML, plus the unified entry point with\r\ncolumn resolution and group handling. See\r\n[the export page](../help/export.md) for the exact split.\r\n\r\nThe xlsx writer honours:\r\n\r\n- **Cell + row styles** via `opts.styles` - read the same `--sg-*`\r\n tokens the grid renders with so the file matches the theme.\r\n- **Page header + footer** lines with text or embedded images\r\n (`opts.header`, `opts.footer`).\r\n- **Embedded images** from cell values when columns are listed in\r\n `opts.imageFields`.\r\n- **Multiple sheets** in one workbook via `opts.sheets`.\r\n- **Printable view** via `pro.print(opts)` - opens a new window with\r\n repeat-on-page headers, cover page, page-size + orientation.\r\n\r\nDemos: [56 theme-matched](../../examples/src/demos/56-export-theme-matched.svelte),\r\n[57 header + footer + logo](../../examples/src/demos/57-export-header-footer-logo.svelte),\r\n[58 images](../../examples/src/demos/58-export-with-images.svelte),\r\n[59 multi-sheet](../../examples/src/demos/59-export-multi-sheet.svelte).\r\n\r\n### [Data import](../help/import.md)\r\n\r\n`pro.importData(opts)` reads an Excel / CSV / TSV / JSON file (or\r\ninline text), maps columns to your row shape, validates each row, and\r\nreturns a typed `ImportResult` with `rows`, `errors`, and a `summary`.\r\nYou decide whether to commit the rows into the grid or preview first.\r\n\r\nDemo: [53 Excel import](../../examples/src/demos/53-excel-import.svelte).\r\n\r\n### AI assistant - now built-in + free\r\n\r\nThe natural-language filter, smart-fill, summarise, classify, chart-this,\r\nfind-anomalies and natural-language-export helpers moved into the free\r\n`@svgrid/grid` package. Import them from `@svgrid/grid` and register a provider\r\nwith `setAIProvider(yourAdapter)` (no model client is bundled - you bring\r\nOpenAI / Anthropic / Ollama / local). See [AI assistant](../help/ai.md). The\r\nonly enterprise touch-point is `aiExport`'s write step, which uses this pack's\r\nexport engine for xlsx / pdf.\r\n\r\nDemo: [51 AI assistant](../../examples/src/demos/51-ai-assistant.svelte).\r\n\r\n### [Pivot tables](../help/pivot.md)\r\n\r\n`createPivotModel(data, config)` (or `pro.pivot.build(config)` against\r\nthe live api) returns `{ rows, columns }` you feed to a separate\r\n`<SvGrid>` instance. Supports row + column axes, eight built-in\r\naggregators (sum/avg/min/max/count/countDistinct/first/last) or\r\ncustom, grand-total row + column, subtotals, custom axis sort.\r\n\r\nDemo: [52 pivot table + designer](../../examples/src/demos/52-pivot-table.svelte).\r\n\r\n## Licensing\r\n\r\nThe pack is **soft-gated**. Until a valid license key is set,\r\neverything still functions but the grid shows a small \"unlicensed\"\r\nwatermark + a one-time console nudge. Set a key once at app startup\r\nand the watermark disappears:\r\n\r\n```ts\r\nimport { setLicenseKey } from '@svgrid/enterprise'\r\nsetLicenseKey('SVENTERPRISE-XXXX-XXXX-XXXX')\r\n```\r\n\r\nFor dev builds, the demos use `setLicenseKey('SVENTERPRISE-DEV-DEMO')` to\r\nsuppress the watermark in screenshots.\r\n\r\nPricing + multi-app licensing: <https://svgrid.com/pricing/>.\r\n\r\n## How Enterprise integrates\r\n\r\n```ts\r\nimport { SvGrid, type SvGridApi } from '@svgrid/grid'\r\nimport { installEnterprise, setLicenseKey, type EnterpriseGridApi } from '@svgrid/enterprise'\r\n\r\nsetLicenseKey(import.meta.env.VITE_SVPRO_KEY)\r\n\r\nlet api = $state<EnterpriseGridApi<typeof features, Order> | null>(null)\r\n```\r\n\r\n```svelte\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n onApiReady={(next: SvGridApi<typeof features, Order>) => {\r\n api = installEnterprise(next)\r\n }}\r\n/>\r\n\r\n<button onclick={() => api?.exportData({ format: 'xlsx', filename: 'orders' })}>\r\n Export to Excel\r\n</button>\r\n```\r\n\r\n`installEnterprise` mutates and returns the same api object - existing\r\nreferences keep working. The `EnterpriseGridApi` type extends `SvGridApi` so\r\nthe grid's existing methods are still there alongside the new ones.\r\n\r\n## Tree-shaking\r\n\r\nEach Enterprise module is a separate subpath export:\r\n\r\n```ts\r\nimport { exportGrid } from '@svgrid/enterprise/export' // export only\r\nimport { importData } from '@svgrid/enterprise/import' // import only\r\nimport { createPivotModel } from '@svgrid/enterprise/pivot' // pivot only\r\n// AI helpers are free + built in: import { aiFilter } from '@svgrid/grid'\r\n```\r\n\r\nIf you only need export, the AI provider plumbing, pivot engine, and\r\nimport parser don't ship. The `@svgrid/enterprise` barrel import is\r\nconvenient; the subpaths are smaller.\r\n\r\n## Peer dependencies\r\n\r\n| Feature | Peer dep | Optional? |\r\n| ------- | -------------------- | ------------------------------------------ |\r\n| xlsx | `jszip` | Yes - only loaded when xlsx is exported. |\r\n| pdf | `pdfmake` | Yes - only loaded when pdf is exported. |\r\n| import | `jszip` (xlsx only) | Yes - only for xlsx import. |\r\n| AI | - | -. You bring your own provider client. |\r\n| pivot | - | -. Pure TypeScript, no runtime deps. |\r\n\r\nInstall only what you need:\r\n\r\n```bash\r\npnpm add @svgrid/enterprise jszip # community + Enterprise + xlsx\r\npnpm add @svgrid/enterprise jszip pdfmake # also pdf\r\n```\r\n\r\n## See also\r\n\r\n- [Getting started](../getting-started.md) - if you haven't seen the\r\n Community walkthrough yet.\r\n- [Help index](../help/index.md) - all topic pages including the four\r\n Enterprise pages.\r\n- [Missing features](../help/missing-features.md) - the honest gap list.\r\n"
2670
2676
  },
2671
2677
  {
2672
2678
  "slug": "enterprise/evaluation",
2673
2679
  "path": "docs/enterprise/evaluation.md",
2674
2680
  "title": "Enterprise evaluation",
2675
- "markdown": "# Enterprise evaluation\r\n\r\nThe `@svgrid/enterprise` package is soft-gated; you can evaluate every\r\nfeature in production-equivalent code paths without contacting\r\nsales. This page is the playbook.\r\n\r\n![Soft-gated evaluation: install, try every feature while a watermark shows, then set a license key when ready, with no gated-off code paths.](/docs-media/enterprise-evaluation.svg)\r\n\r\n## Step 1: Install\r\n\r\n```bash\r\npnpm add @svgrid/enterprise\r\n# Add the peers for the features you want to evaluate:\r\npnpm add jszip # xlsx export/import\r\npnpm add pdfmake # pdf export\r\n```\r\n\r\n`jszip` and `pdfmake` are lazy-loaded by @svgrid/enterprise - they're only\r\nrequired if you actually invoke the matching feature.\r\n\r\n## Step 2: Install the evaluation key\r\n\r\n```ts\r\nimport { setLicenseKey } from '@svgrid/enterprise'\r\nsetLicenseKey('SVENTERPRISE-DEV-DEMO')\r\n```\r\n\r\nThis suppresses the watermark in local dev. **For staging /\r\nproduction evaluation, request an evaluation key** at\r\n[svgrid.com/contact](https://svgrid.com/contact/) - no sales call\r\nrequired.\r\n\r\nThe evaluation key is a real key with a 30-day expiry. Behaves\r\nidentically to a paid license; lets you ship internal staging /\r\ndemo deployments to evaluators without the watermark.\r\n\r\n### What unlicensed looks like\r\n\r\nWith no key set, Enterprise stays fully functional but nudges you:\r\n\r\n- A small **\"www.svgrid.com\" watermark** in the corner of each grid\r\n (fades after 5 seconds).\r\n- The first time you actually invoke a Enterprise feature (export, import,\r\n print, AI), a one-time **upgrade card** appears in the bottom-right\r\n naming that feature, with a one-click link to start a free trial. It\r\n shows at most once per session.\r\n\r\nBoth are pure DOM - **no network calls, no cookies, no web storage**\r\n(see [security](../help/security.md)). `setLicenseKey()` with any\r\nvalid key suppresses them before they appear. To remove the upgrade\r\ncard programmatically (e.g. you render your own upgrade UI), call:\r\n\r\n```ts\r\nimport { dismissUpgradePrompt } from '@svgrid/enterprise'\r\ndismissUpgradePrompt()\r\n```\r\n\r\n## Step 3: Wire up\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid, type SvGridApi } from '@svgrid/grid'\r\n import { installEnterprise, type EnterpriseGridApi } from '@svgrid/enterprise'\r\n\r\n let api = $state<EnterpriseGridApi<typeof features, Order> | null>(null)\r\n</script>\r\n\r\n<SvGrid\r\n data={rows} columns={columns} features={features}\r\n onApiReady={(next: SvGridApi<typeof features, Order>) => {\r\n api = installEnterprise(next)\r\n }}\r\n/>\r\n\r\n<button onclick={() => api?.exportData({ format: 'xlsx' })}>Export</button>\r\n```\r\n\r\nThat's the integration. The Community grid is unchanged; Enterprise\r\naugments the api object.\r\n\r\n## Step 4: Try the features (30-min tour)\r\n\r\n| Feature | One-line evaluation |\r\n| ------- | -------------------------------------------------------------------- |\r\n| Export | `api.exportData({ format: 'xlsx', filename: 'data' })` |\r\n| Pivot | `const pivot = createPivotModel(rows, { rows: ['region'], cols: ['quarter'], values: [{ field: 'amount', agg: 'sum' }] })` |\r\n| Import | `<input type=\"file\" onchange={(e) => api.importData({ file: e.target.files[0] }).then(r => api.addRows(r.rows))}>` |\r\n| AI | `setAIProvider(yourAdapter); const plan = await api.ai.filter('show last quarter > $10k')` |\r\n\r\nEach Enterprise feature has a fully-working demo in the gallery\r\n([56-60 + 51 + 52 + 53](https://svgrid.com/demos/)) that you can\r\nread end-to-end.\r\n\r\n## Step 5: Performance + budget check\r\n\r\nBundle sizes (gzip):\r\n\r\n| Surface | Size | Notes |\r\n| --------------- | ----- | ---------------------------------- |\r\n| Community only | 80 kB | Renderer + engine (+ 9 kB CSS) |\r\n| + Enterprise export | +12 kB| + `jszip` peer when xlsx is used |\r\n| + Enterprise pdf | +90 kB| + `pdfmake` peer when pdf is used |\r\n| + Enterprise pivot | +6 kB | Pure TS, no peers |\r\n| + Enterprise import | +5 kB | + `jszip` for xlsx import |\r\n\r\nSubpath imports (`@svgrid/enterprise/export`, `@svgrid/enterprise/pivot`, etc.)\r\nensure you only pay for what you use.\r\n\r\n## Step 6: Decide\r\n\r\n- Shipping one production app? **Single Application Developer License**\r\n ($599 per developer).\r\n- Shipping multiple apps across your org? **Multiple Application\r\n Developer License** ($999 per developer).\r\n- Large team (5+), multi-year, NDA, or PO? **Enterprise / volume**\r\n (contact sales).\r\n\r\nEach is a perpetual license + 1 year of updates and support that renews\r\nautomatically; cancel anytime.\r\n\r\n[Full pricing](https://svgrid.com/pricing/).\r\n\r\n## Migrating from another grid mid-evaluation\r\n\r\nIf you're swapping out an existing grid, see the\r\n[migration guides](../help/migrating-from-ag-grid.md) - typically a half-day\r\nport for a 5-grid app.\r\n\r\n## See also\r\n\r\n- [Enterprise licensing](./licensing.md) - what each tier covers\r\n- [Enterprise support](./support.md) - what you get with each tier\r\n- [Missing features](../help/missing-features.md) - the honest gap list\r\n"
2681
+ "markdown": "# Enterprise evaluation\r\n\r\nThe `@svgrid/enterprise` package is soft-gated; you can evaluate every\r\nfeature in production-equivalent code paths without contacting\r\nsales. This page is the playbook.\r\n\r\n![Soft-gated evaluation: install, try every feature while a watermark shows, then set a license key when ready, with no gated-off code paths.](/docs-media/enterprise-evaluation.svg)\r\n\r\n## Step 1: Install\r\n\r\n```bash\r\npnpm add @svgrid/enterprise\r\n# Add the peers for the features you want to evaluate:\r\npnpm add jszip # xlsx export/import\r\npnpm add pdfmake # pdf export\r\n```\r\n\r\n`jszip` and `pdfmake` are lazy-loaded by @svgrid/enterprise - they're only\r\nrequired if you actually invoke the matching feature.\r\n\r\n## Step 2: Install the evaluation key\r\n\r\n```ts\r\nimport { setLicenseKey } from '@svgrid/enterprise'\r\nsetLicenseKey('SVENTERPRISE-DEV-DEMO')\r\n```\r\n\r\nThis suppresses the watermark in local dev. **For staging /\r\nproduction evaluation, request an evaluation key** at\r\n[svgrid.com/contact](https://svgrid.com/contact/) - no sales call\r\nrequired.\r\n\r\nThe evaluation key is a real key with a 30-day expiry. Behaves\r\nidentically to a paid license; lets you ship internal staging /\r\ndemo deployments to evaluators without the watermark.\r\n\r\n### What unlicensed looks like\r\n\r\nWith no key set, Enterprise stays fully functional but nudges you:\r\n\r\n- A small **\"www.svgrid.com\" watermark** in the corner of each grid\r\n (fades after 5 seconds).\r\n- The first time you actually invoke a Enterprise feature (export, import,\r\n print, AI), a one-time **upgrade card** appears in the bottom-right\r\n naming that feature, with a one-click link to start a free trial. It\r\n shows at most once per session.\r\n\r\nBoth are pure DOM - **no network calls, no cookies, no web storage**\r\n(see [security](../help/security.md)). `setLicenseKey()` with any\r\nvalid key suppresses them before they appear. To remove the upgrade\r\ncard programmatically (e.g. you render your own upgrade UI), call:\r\n\r\n```ts\r\nimport { dismissUpgradePrompt } from '@svgrid/enterprise'\r\ndismissUpgradePrompt()\r\n```\r\n\r\n## Step 3: Wire up\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid, type SvGridApi } from '@svgrid/grid'\r\n import { installEnterprise, type EnterpriseGridApi } from '@svgrid/enterprise'\r\n\r\n let api = $state<EnterpriseGridApi<typeof features, Order> | null>(null)\r\n</script>\r\n\r\n<SvGrid\r\n data={rows} columns={columns} features={features}\r\n onApiReady={(next: SvGridApi<typeof features, Order>) => {\r\n api = installEnterprise(next)\r\n }}\r\n/>\r\n\r\n<button onclick={() => api?.exportData({ format: 'xlsx' })}>Export</button>\r\n```\r\n\r\nThat's the integration. The Community grid is unchanged; Enterprise\r\naugments the api object.\r\n\r\n## Step 4: Try the features (30-min tour)\r\n\r\n| Feature | One-line evaluation |\r\n| ------- | -------------------------------------------------------------------- |\r\n| Export | `api.exportData({ format: 'xlsx', filename: 'data' })` |\r\n| Pivot | `const pivot = createPivotModel(rows, { rows: ['region'], cols: ['quarter'], values: [{ field: 'amount', agg: 'sum' }] })` |\r\n| Import | `<input type=\"file\" onchange={(e) => api.importData({ file: e.target.files[0] }).then(r => api.addRows(r.rows))}>` |\r\n| AI | `setAIProvider(yourAdapter); const plan = await api.ai.filter('show last quarter > $10k')` |\r\n\r\nEach Enterprise feature has a fully-working demo in the gallery\r\n([56-60 + 51 + 52 + 53](https://svgrid.com/demos/)) that you can\r\nread end-to-end.\r\n\r\n## Step 5: Performance + budget check\r\n\r\nBundle sizes (gzip):\r\n\r\n| Surface | Size | Notes |\r\n| --------------- | ----- | ---------------------------------- |\r\n| Community only | 77 kB | Renderer + engine (+ 9 kB CSS) |\r\n| + Enterprise export | +12 kB| + `jszip` peer when xlsx is used |\r\n| + Enterprise pdf | +90 kB| + `pdfmake` peer when pdf is used |\r\n| + Enterprise pivot | +6 kB | Pure TS, no peers |\r\n| + Enterprise import | +5 kB | + `jszip` for xlsx import |\r\n\r\nSubpath imports (`@svgrid/enterprise/export`, `@svgrid/enterprise/pivot`, etc.)\r\nensure you only pay for what you use.\r\n\r\n## Step 6: Decide\r\n\r\n- Shipping one production app? **Single Application Developer License**\r\n ($599 per developer).\r\n- Shipping multiple apps across your org? **Multiple Application\r\n Developer License** ($999 per developer).\r\n- Large team (5+), multi-year, NDA, or PO? **Enterprise / volume**\r\n (contact sales).\r\n\r\nEach is a perpetual license + 1 year of updates and support that renews\r\nautomatically; cancel anytime.\r\n\r\n[Full pricing](https://svgrid.com/pricing/).\r\n\r\n## Migrating from another grid mid-evaluation\r\n\r\nIf you're swapping out an existing grid, see the\r\n[migration guides](../help/migrating-from-ag-grid.md) - typically a half-day\r\nport for a 5-grid app.\r\n\r\n## See also\r\n\r\n- [Enterprise licensing](./licensing.md) - what each tier covers\r\n- [Enterprise support](./support.md) - what you get with each tier\r\n- [Missing features](../help/missing-features.md) - the honest gap list\r\n"
2682
+ },
2683
+ {
2684
+ "slug": "enterprise/getting-started",
2685
+ "path": "docs/enterprise/getting-started.md",
2686
+ "title": "Enterprise getting started: a complete example",
2687
+ "markdown": "# Enterprise getting started: a complete example\r\n\r\nOne page, one file, from an empty folder to a working grid with Excel, PDF and\r\nCSV export. Nothing is elided - every command and every line below was run\r\nend to end against the published packages.\r\n\r\nIf you already have a project, skip to [step 2](#2-install).\r\n\r\n## 1. Create a project\r\n\r\n```bash\r\nnpx sv create svgrid-trial --template minimal --types ts --no-add-ons\r\ncd svgrid-trial\r\n```\r\n\r\nThose flags skip every interactive prompt, so the sequence is copy-pasteable.\r\nFor a plain Vite app instead, `npm create @svgrid@latest` scaffolds one with\r\nthe grid already wired in - the component below drops into either.\r\n\r\n## 2. Install\r\n\r\n```bash\r\nnpm install @svgrid/grid @svgrid/enterprise\r\nnpm install jszip pdfmake # optional peers, for Excel and PDF export\r\n```\r\n\r\n`jszip` and `pdfmake` are optional peer dependencies, lazy-loaded the first\r\ntime you actually call an export. Skip them if you only need CSV, TSV or HTML;\r\n`exportData` throws a message naming the missing package if you call a format\r\nwhose peer is absent.\r\n\r\n**No build configuration is required.** If you are on `@svgrid/enterprise`\r\n2.5.x or earlier, see [older versions](#older-versions) below - those releases\r\nshipped TypeScript source and needed two `optimizeDeps` entries in\r\n`vite.config.js`.\r\n\r\n## 3. The component\r\n\r\nReplace the contents of `src/routes/+page.svelte` (or `src/App.svelte` in a\r\nVite project) with this. It is self-contained: licence, theme, grid and\r\nexports, with nothing else to wire up.\r\n\r\n```svelte\r\n<script>\r\n import { SvGrid } from '@svgrid/grid'\r\n import { installEnterprise, setLicenseKey } from '@svgrid/enterprise'\r\n // One of 20 themes that ship with the package. Swap the id for material,\r\n // nord, dracula, fluent, carbon, ag-alpine, and so on. Each carries a full\r\n // light AND dark palette; dark activates on <html data-theme=\"dark\">.\r\n import '@svgrid/grid/themes/shadcn.css'\r\n\r\n // Once, before any Enterprise feature runs. Use your own key here; see\r\n // ./evaluation.md for how to get an evaluation key.\r\n setLicenseKey('SVENTERPRISE-DEV-DEMO')\r\n\r\n let api = $state(null)\r\n let status = $state('')\r\n\r\n // Capabilities are boolean props - `sortable`, `filterable`, `editable`,\r\n // `groupable`, `pageable` - and each injects the feature it needs. For finer\r\n // control, register features explicitly with `tableFeatures({ ... })` and\r\n // pass them as `features`.\r\n\r\n // Starts 'light' rather than reading the DOM, so this file is safe to render\r\n // on the server too. The effect below syncs it once we are in the browser.\r\n let theme = $state('light')\r\n\r\n $effect(() => {\r\n const saved = localStorage.getItem('theme')\r\n if (saved) theme = saved\r\n })\r\n\r\n $effect(() => {\r\n document.documentElement.dataset.theme = theme\r\n try {\r\n localStorage.setItem('theme', theme)\r\n } catch (e) {\r\n // Private mode / storage disabled. The toggle still works for this tab.\r\n }\r\n })\r\n\r\n // Your data. Swap for a fetch() in onMount, a load function, or props.\r\n let rows = $state([\r\n { id: 1, name: 'Ada Lovelace', team: 'Engineering', salary: 145000, active: true },\r\n { id: 2, name: 'Alan Turing', team: 'Research', salary: 160000, active: true },\r\n { id: 3, name: 'Grace Hopper', team: 'Engineering', salary: 152000, active: false },\r\n { id: 4, name: 'Katherine Johnson', team: 'Data', salary: 138000, active: true },\r\n { id: 5, name: 'Edsger Dijkstra', team: 'Research', salary: 149000, active: false },\r\n ])\r\n\r\n const columns = [\r\n { field: 'name', header: 'Name', editorType: 'text', width: 200 },\r\n { field: 'team', header: 'Team', editorType: 'text', width: 150 },\r\n {\r\n field: 'salary',\r\n header: 'Salary',\r\n width: 130,\r\n align: 'right',\r\n format: { type: 'currency', currency: 'USD' },\r\n },\r\n { field: 'active', header: 'Active', editorType: 'checkbox', width: 90 },\r\n ]\r\n\r\n // installEnterprise() augments the grid API with the Pro methods:\r\n // exportData, importData, print, pivot, AI.\r\n async function run(label, fn) {\r\n if (!api) return\r\n try {\r\n await fn()\r\n status = `${label} ready`\r\n } catch (err) {\r\n status = `${label} failed: ${err instanceof Error ? err.message : String(err)}`\r\n }\r\n }\r\n</script>\r\n\r\n<main>\r\n <header>\r\n <div>\r\n <h1>SvGrid Enterprise</h1>\r\n <p>Sort, filter, select, and double-click a cell to edit.</p>\r\n </div>\r\n <button\r\n type=\"button\"\r\n onclick={() => (theme = theme === 'dark' ? 'light' : 'dark')}\r\n aria-label=\"Switch to {theme === 'dark' ? 'light' : 'dark'} mode\"\r\n >\r\n {theme === 'dark' ? 'Light' : 'Dark'}\r\n </button>\r\n </header>\r\n\r\n <div class=\"grid-shell\">\r\n <SvGrid\r\n data={rows}\r\n {columns}\r\n sortable\r\n filterable\r\n editable\r\n selectionMode=\"row\"\r\n showRowSelection={true}\r\n showRowNumbers={true}\r\n rowHeight={38}\r\n containerHeight=\"100%\"\r\n fitColumns={true}\r\n onApiReady={(next) => (api = installEnterprise(next))}\r\n />\r\n </div>\r\n\r\n <div class=\"actions\">\r\n <button type=\"button\" onclick={() => run('CSV', () => api.exportData({ format: 'csv', filename: 'team' }))}>\r\n Export CSV\r\n </button>\r\n <button type=\"button\" onclick={() => run('Excel', () => api.exportData({ format: 'xlsx', filename: 'team' }))}>\r\n Export Excel\r\n </button>\r\n <button type=\"button\" onclick={() => run('PDF', () => api.exportData({ format: 'pdf', filename: 'team' }))}>\r\n Export PDF\r\n </button>\r\n <button type=\"button\" onclick={() => run('Print', () => api.print())}>Print</button>\r\n {#if status}<span class=\"status\">{status}</span>{/if}\r\n </div>\r\n\r\n <p class=\"hint\">\r\n Export respects the current sort, filter and grouping. Change the theme\r\n import at the top of this file to re-skin the grid and this page together.\r\n </p>\r\n</main>\r\n\r\n<style>\r\n /* Page chrome reads the same --sg-* tokens as the grid, so it re-themes with it. */\r\n :global(body) {\r\n margin: 0;\r\n background: var(--sg-bg);\r\n color: var(--sg-fg);\r\n font-family: var(--sg-font, system-ui, sans-serif);\r\n }\r\n\r\n main { max-width: 760px; margin: 3rem auto; padding: 0 1rem; }\r\n header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }\r\n h1 { margin: 0; font-size: 1.4rem; }\r\n p { color: var(--sg-muted); }\r\n .actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin-top: 1rem; }\r\n\r\n button {\r\n flex: none;\r\n padding: 0.4rem 0.8rem;\r\n border: 1px solid var(--sg-border);\r\n border-radius: var(--sg-radius, 6px);\r\n background: var(--sg-bg-subtle, transparent);\r\n color: var(--sg-fg);\r\n font: inherit;\r\n font-size: 0.85rem;\r\n cursor: pointer;\r\n }\r\n button:hover { background: var(--sg-row-hover-bg); }\r\n button:focus-visible { outline: 2px solid var(--sg-accent); outline-offset: 2px; }\r\n\r\n .status { font-size: 0.82rem; color: var(--sg-muted); }\r\n .grid-shell { height: 320px; }\r\n .hint { font-size: 0.85rem; }\r\n</style>\r\n```\r\n\r\n## 4. Run it\r\n\r\n```bash\r\nnpm run dev\r\n```\r\n\r\nOpen <http://localhost:5173>. You should get a five-row grid: click a header to\r\nsort, use the funnel for Excel-style filtering, double-click a cell to edit,\r\nand the four buttons write real files.\r\n\r\n## What the three pieces do\r\n\r\n| Line | Why it's there |\r\n| --- | --- |\r\n| `setLicenseKey(...)` | Runs once, before any Enterprise call. Without it the pack still works, but watermarks and nudges the app. See [licensing](./licensing.md). |\r\n| `installEnterprise(next)` | Wraps the `SvGridApi` from `onApiReady` and returns it with `exportData`, `importData`, `print`, pivot and AI attached. The `<SvGrid>` component itself stays Community. |\r\n| `import '@svgrid/grid/themes/shadcn.css'` | Optional. Declares the `--sg-*` tokens for one of 20 presets. Without it the grid still renders, using the built-in fallbacks. |\r\n\r\n## Server-side rendering\r\n\r\nThe component above is SSR-safe as written: `theme` starts at a literal, and\r\nevery DOM and `localStorage` access sits inside `$effect`, which only runs in\r\nthe browser. No `export const ssr = false` is needed.\r\n\r\nIf you move DOM access to module scope or into component initialisation, it\r\nwill run on the server and throw. Keep it in `$effect` or `onMount`.\r\n\r\n## Older versions\r\n\r\n`@svgrid/enterprise` 2.5.x and earlier shipped TypeScript source rather than a\r\nbuilt bundle. Vite's dependency pre-bundler cannot parse the `.svelte.ts` rune\r\nmodules in it, so those versions need two entries in `vite.config.js`:\r\n\r\n```js\r\noptimizeDeps: {\r\n // Without this the dev server fails to start:\r\n // RolldownError ... Unexpected token (on `import type`)\r\n exclude: ['@svgrid/grid', '@svgrid/enterprise'],\r\n // Excluding it also stops ITS imports being pre-bundled, and jszip and\r\n // pdfmake are CommonJS. Without this, xlsx and pdf export fail with\r\n // \"JSZip is not a constructor\" / \"pdfMake.createPdf is not a function\".\r\n include: ['jszip', 'pdfmake/build/pdfmake', 'pdfmake/build/vfs_fonts'],\r\n},\r\n```\r\n\r\n2.6.0 moved the package to a built `dist`, so neither entry is needed. Upgrading\r\nis the better fix.\r\n\r\n## Next\r\n\r\n- [Evaluation playbook](./evaluation.md) - what unlicensed looks like, and how\r\n to get an evaluation key.\r\n- [Licensing](./licensing.md) - key formats, seats, renewals.\r\n- [Data export](../help/export.md) - styles, headers, images, multi-sheet.\r\n- [Data import](../help/import.md) - column mapping and per-row validation.\r\n"
2676
2688
  },
2677
2689
  {
2678
2690
  "slug": "enterprise/licensing",
@@ -2702,7 +2714,7 @@ export const docs = [
2702
2714
  "slug": "enterprise/studio/ai-generation",
2703
2715
  "path": "docs/enterprise/studio/ai-generation.md",
2704
2716
  "title": "AI generation",
2705
- "markdown": "# AI generation\r\n\r\nThe `@svgrid/mcp` server exposes Studio to AI coding agents (Claude Code, Cursor,\r\nCodex, ...) through the Model Context Protocol. Ask your agent to build a screen\r\nfor a table and it introspects, scaffolds, and verifies - producing the same code\r\nthe [CLI](./cli.md) and [designer](./designer.md) do.\r\n\r\n![The generated files: schema module, +server.ts API route, and +page.svelte screen, with svgrid:managed markers.](/docs-media/studio-generated-code.png)\r\n\r\n## How it fits together\r\n\r\nThe MCP server makes **no model calls of its own**. It hands your agent a set of\r\ntools; the agent's own model decides when to call them. So the loop is:\r\n\r\n```\r\nyou -> your agent (its model) -> svgrid MCP tools -> files on disk\r\n ^ |\r\n +-------- svelte-check verify <----------+\r\n```\r\n\r\nYour schema and data stay on your machine; nothing is sent to our servers.\r\n\r\n## Configure the MCP server\r\n\r\nAdd it to your agent's MCP config (the key is passed as an env var, since the\r\nserver runs in a Node process):\r\n\r\n```jsonc\r\n{\r\n \"mcpServers\": {\r\n \"svgrid\": {\r\n \"command\": \"npx\",\r\n \"args\": [\"@svgrid/mcp\"],\r\n \"env\": { \"SVGRID_LICENSE_KEY\": \"SVENTERPRISE-...\" }\r\n }\r\n }\r\n}\r\n```\r\n\r\nThe same block works across hosts - only the file it lives in differs:\r\n\r\n| Host | Config location |\r\n| --- | --- |\r\n| Claude Code | `.mcp.json` at the project root, or `claude mcp add` |\r\n| Cursor | `.cursor/mcp.json` |\r\n| Windsurf | `~/.codeium/windsurf/mcp_config.json` |\r\n| Codex / other | the host's `mcpServers` config |\r\n\r\nRestart (or reload) the agent so it picks up the server, then confirm the\r\n`svgrid` tools are listed.\r\n\r\n## The tools\r\n\r\nAlongside the read-only knowledge tools (examples, docs, API reference), the\r\nserver exposes two generation tools:\r\n\r\n| Tool | What it does |\r\n| --- | --- |\r\n| `introspect_source` | Infer an `EntitySchema` from a Drizzle schema file (`kind:\"drizzle\"`) or sample JSON rows (`kind:\"json\"`). Returns a **draft** to review. |\r\n| `scaffold_entity` | Generate the SvelteKit files from an `EntitySchema`. The output is **compile-verified** (the generated page is run through the Svelte compiler) before it comes back, and each file carries `svgrid:managed` markers. |\r\n\r\n## Drive the whole project model\r\n\r\nBeyond single screens, the server exposes the full\r\n[project model](./concepts.md#the-project-model) - the same\r\n`studio.config.json` the visual designer edits - as a set of `studio_*` tools.\r\nYour agent can build a complete multi-screen app, or continue editing one the\r\ndesigner produced, and hand it back:\r\n\r\n| Tool | What it does |\r\n| --- | --- |\r\n| `studio_new_project` | Start a new, empty project |\r\n| `studio_load_project` | Load an existing `studio.config.json` to continue editing it |\r\n| `studio_describe_project` | Summarize the current project: entities, screens + block ids, theme, RBAC, auth, deploy |\r\n| `studio_get_config` | Return the project as a `studio.config.json` string - write it to disk and the designer opens it (round-trip) |\r\n| `studio_capabilities` | List what can be added: block kinds, UI component keys, theme presets, source kinds, deploy targets |\r\n| `studio_add_entity` | Add an entity + its default screen, from an `EntitySchema`, a Drizzle source, or sample JSON rows |\r\n| `studio_add_screen` | Add an entity-bound screen (default grid) or a freestanding page |\r\n| `studio_add_block` | Add a data block (grid, chart, kpi, gauge, tree, tabs, accordion, pivot, board, calendar, detail, master-detail, filter, record, lookup, dashboard) to a screen |\r\n| `studio_add_component` | Add a UI component block (button, badge, alert, card, stat, timeline, sparkline, chip, ...) with prop overrides |\r\n| `studio_update_block` | **Configure** an existing block - columns, editing mode, export buttons, grouping, chart dimension/measure, row links, format rules - plus its width, height, and class |\r\n| `studio_remove_block` | Remove a block from a screen |\r\n| `studio_move_block` | Reorder a block within its screen |\r\n| `studio_update_screen` | Rename a screen, change its route or nav entry, or set `renderMode` (`ssr` for an idiomatic `+page.server.ts` load + form actions, `spa` for the client page) |\r\n| `studio_remove_screen` | Remove a screen and its blocks |\r\n| `studio_set_screen_layout` | Switch a screen between `grid`, `stack`, `split`, `dock`, and `canvas` layouts |\r\n| `studio_set_entity_source` | Bind an entity to a data source: `sql`, `supabase`, `rest`, `pglite`, or `memory` |\r\n| `studio_set_theme` | Set the theme preset, light/dark mode, and accent color |\r\n| `studio_set_access` | Configure [RBAC](./access-control.md): roles gating screens and create/update/delete actions |\r\n| `studio_set_auth` | Configure the [auth starter](./auth.md): protect, register, user admin, 2FA, email, OAuth (`github` / `google` / `oidc`) |\r\n| `studio_set_data_layer` | Turn the typed Drizzle data layer (schema + repositories + migrations) on or off |\r\n| `studio_set_tenancy` | Turn [multi-tenancy](./access-control.md#multi-tenancy) on/off - scopes every row to the caller's tenant, enforced server-side; `sharedEntities` stay global |\r\n| `studio_set_job` | Schedule a background job (`email` digest or `code`) - emits the guarded `/api/cron` route + the platform schedule; omit `cron` to remove one |\r\n| `studio_set_deploy_target` | Set `auto` / `vercel` / `netlify` / `cloudflare` / `node` - picks the adapter and emits CI/CD config |\r\n| `studio_validate` | Validate the current project; returns errors + warnings |\r\n| `studio_generate_app` | Emit the full runnable SvelteKit app - every file, ready to write and `svelte-check` |\r\n\r\nA prompt that exercises the loop end to end:\r\n\r\n> \"Using the svgrid MCP: new project 'Support desk'. Add a `tickets` entity from\r\n> these sample rows, a dashboard screen with a KPI and a chart over tickets,\r\n> RBAC with an agent role that cannot delete, dark theme, then validate and\r\n> generate the app.\"\r\n\r\n## Step by step\r\n\r\n1. **Point it at a source.** A Drizzle schema file, or a handful of sample rows.\r\n2. **Introspect.** The agent calls `introspect_source` and shows you the drafted\r\n `EntitySchema` - field names, types, primary key, guessed formats.\r\n3. **Refine (optional).** Correct a type, mark a field hidden or read-only, add\r\n validation - in chat, or later in the [visual designer](./app-designer.md).\r\n4. **Scaffold.** The agent calls `scaffold_entity`; the files come back already\r\n run through the Svelte compiler.\r\n5. **Verify.** The agent runs your project's `svelte-check`; if anything fails it\r\n iterates. This is the loop that keeps AI output trustworthy.\r\n\r\n## Prompts that work\r\n\r\nFrom a Drizzle schema:\r\n\r\n> \"Using the svgrid MCP, build a CRUD screen for the `customers` table in\r\n> `src/lib/db/schema.ts`.\"\r\n\r\nFrom sample data, when there is no schema yet:\r\n\r\n> \"Here are five example rows of our invoices. Use the svgrid MCP to introspect a\r\n> schema, then scaffold a CRUD screen at `/invoices`.\"\r\n>\r\n> ```json\r\n> [{ \"id\": \"INV-1\", \"customer\": \"Acme\", \"amount\": 4200, \"paid\": true, \"due\": \"2026-07-01\" }]\r\n> ```\r\n\r\nRefining before you commit:\r\n\r\n> \"Show me the drafted schema first. Mark `internalNotes` hidden, make `email`\r\n> required, and set `status` to an enum of draft/sent/paid before scaffolding.\"\r\n\r\n## What comes back\r\n\r\n`scaffold_entity` writes three files (the same layout as the CLI and designer):\r\n\r\n```\r\nsrc/lib/customers.schema.ts # the EntitySchema + row type\r\nsrc/routes/api/customers/+server.ts # createKitHandlers data endpoint\r\nsrc/routes/customers/+page.svelte # the grid + edit-panel screen\r\n```\r\n\r\nEach carries `svgrid:managed` markers so a re-generation updates the managed\r\nregions and leaves your hand-written code untouched. See\r\n[code generation](./code-generation.md) for the anatomy of each file.\r\n\r\n## Bring your own key\r\n\r\nThe generator uses **your** agent's model and API key - your schema and data\r\nnever touch our servers. The MCP server itself makes no model calls; it provides\r\nintrospection + scaffolding + verification tools that the host agent drives.\r\n\r\n## Licensing\r\n\r\nGeneration is soft-gated: it runs unlicensed and prepends a one-line commercial\r\nnotice, and the generated app carries the usual watermark until you call\r\n`setLicenseKey()`. Set `SVGRID_LICENSE_KEY` in the MCP config to license it. See\r\n[licensing](../licensing.md#studio-data-app-generator).\r\n\r\n## See also\r\n\r\n- [The Studio CLI](./cli.md) - the deterministic, no-AI path\r\n- [Visual app designer](./app-designer.md) - refine an AI draft by hand before generating\r\n- [Code generation](./code-generation.md) - the anatomy of the emitted files\r\n- [MCP server](../../help/mcp-server.md) - full MCP reference\r\n"
2717
+ "markdown": "# AI generation\r\n\r\nThe `@svgrid/mcp` server exposes Studio to AI coding agents (Claude Code, Cursor,\r\nCodex, ...) through the Model Context Protocol. Ask your agent to build a screen\r\nfor a table and it introspects, scaffolds, and verifies - producing the same code\r\nthe [CLI](./cli.md) and [designer](./designer.md) do.\r\n\r\n![The generated files: schema module, +server.ts API route, and +page.svelte screen, with svgrid:managed markers.](/docs-media/studio-generated-code.png)\r\n\r\n## How it fits together\r\n\r\nThe MCP server makes **no model calls of its own**. It hands your agent a set of\r\ntools; the agent's own model decides when to call them. So the loop is:\r\n\r\n```\r\nyou -> your agent (its model) -> svgrid MCP tools -> files on disk\r\n ^ |\r\n +-------- svelte-check verify <----------+\r\n```\r\n\r\nYour schema and data stay on your machine; nothing is sent to our servers.\r\n\r\n## Configure the MCP server\r\n\r\nAdd it to your agent's MCP config (the key is passed as an env var, since the\r\nserver runs in a Node process):\r\n\r\n```jsonc\r\n{\r\n \"mcpServers\": {\r\n \"svgrid\": {\r\n \"command\": \"npx\",\r\n \"args\": [\"@svgrid/mcp\"],\r\n \"env\": { \"SVGRID_LICENSE_KEY\": \"SVENTERPRISE-...\" }\r\n }\r\n }\r\n}\r\n```\r\n\r\nThe same block works across hosts - only the file it lives in differs:\r\n\r\n| Host | Config location |\r\n| --- | --- |\r\n| Claude Code | `.mcp.json` at the project root, or `claude mcp add` |\r\n| Cursor | `.cursor/mcp.json` |\r\n| Windsurf | `~/.codeium/windsurf/mcp_config.json` |\r\n| Codex / other | the host's `mcpServers` config |\r\n\r\nRestart (or reload) the agent so it picks up the server, then confirm the\r\n`svgrid` tools are listed.\r\n\r\n## The tools\r\n\r\nAlongside the read-only knowledge tools (examples, docs, API reference), the\r\nserver exposes two generation tools:\r\n\r\n| Tool | What it does |\r\n| --- | --- |\r\n| `introspect_source` | Infer an `EntitySchema` from a Drizzle schema file (`kind:\"drizzle\"`) or sample JSON rows (`kind:\"json\"`). Returns a **draft** to review. |\r\n| `scaffold_entity` | Generate the SvelteKit files from an `EntitySchema`. The output is **compile-verified** (the generated page is run through the Svelte compiler) before it comes back, and each file carries `svgrid:managed` markers. |\r\n\r\n## Drive the whole project model\r\n\r\nBeyond single screens, the server exposes the full\r\n[project model](./concepts.md#the-project-model) - the same\r\n`studio.config.json` the visual designer edits - as a set of `studio_*` tools.\r\nYour agent can build a complete multi-screen app, or continue editing one the\r\ndesigner produced, and hand it back:\r\n\r\n| Tool | What it does |\r\n| --- | --- |\r\n| `studio_new_project` | Start a new, empty project |\r\n| `studio_load_project` | Load an existing `studio.config.json` to continue editing it |\r\n| `studio_describe_project` | Summarize the current project: entities, screens + block ids, theme, RBAC, auth, deploy |\r\n| `studio_get_config` | Return the project as a `studio.config.json` string - write it to disk and the designer opens it (round-trip) |\r\n| `studio_capabilities` | List what can be added: block kinds, UI component keys, theme presets, source kinds, deploy targets |\r\n| `studio_add_entity` | Add an entity + its default screen, from an `EntitySchema`, a Drizzle source, or sample JSON rows |\r\n| `studio_add_screen` | Add an entity-bound screen (default grid) or a freestanding page |\r\n| `studio_add_block` | Add a data block (grid, chart, kpi, gauge, tree, tabs, accordion, pivot, board, calendar, detail, master-detail, filter, record, lookup, dashboard) to a screen |\r\n| `studio_add_component` | Add a UI component block (button, badge, alert, card, stat, timeline, sparkline, chip, ...) with prop overrides |\r\n| `studio_update_block` | **Configure** an existing block - columns, editing mode, export buttons, grouping, chart dimension/measure, row links, format rules - plus its width, height, and class |\r\n| `studio_remove_block` | Remove a block from a screen |\r\n| `studio_move_block` | Reorder a block within its screen |\r\n| `studio_update_screen` | Rename a screen, change its route or nav entry, or set `renderMode` (`ssr` for an idiomatic `+page.server.ts` load + form actions, `spa` for the client page) |\r\n| `studio_remove_screen` | Remove a screen and its blocks |\r\n| `studio_set_screen_layout` | Switch a screen between `grid`, `stack`, `split`, `dock`, and `canvas` layouts |\r\n| `studio_set_form_layout` | Arrange an entity's [create/edit form](./edit-forms.md): column count + titled sections, or `\"suggest\": true` to have them proposed from the field names |\r\n| `studio_set_field_conditions` | Make a form field [value-driven](./edit-forms.md#fields-that-react-to-the-answers) - shown, required, or locked depending on the other answers |\r\n| `studio_set_entity_source` | Bind an entity to a data source: `sql`, `supabase`, `rest`, `pglite`, or `memory` |\r\n| `studio_set_theme` | Set the theme preset, light/dark mode, and accent color |\r\n| `studio_set_access` | Configure [RBAC](./access-control.md): roles gating screens and create/update/delete actions |\r\n| `studio_set_auth` | Configure the [auth starter](./auth.md): protect, register, user admin, 2FA, email, OAuth (`github` / `google` / `oidc`) |\r\n| `studio_set_data_layer` | Turn the typed Drizzle data layer (schema + repositories + migrations) on or off |\r\n| `studio_set_tenancy` | Turn [multi-tenancy](./access-control.md#multi-tenancy) on/off - scopes every row to the caller's tenant, enforced server-side; `sharedEntities` stay global |\r\n| `studio_set_job` | Schedule a background job (`email` digest or `code`) - emits the guarded `/api/cron` route + the platform schedule; omit `cron` to remove one |\r\n| `studio_set_deploy_target` | Set `auto` / `vercel` / `netlify` / `cloudflare` / `node` - picks the adapter and emits CI/CD config |\r\n| `studio_validate` | Validate the current project; returns errors + warnings |\r\n| `studio_generate_app` | Emit the full runnable SvelteKit app - every file, ready to write and `svelte-check` |\r\n\r\nA prompt that exercises the loop end to end:\r\n\r\n> \"Using the svgrid MCP: new project 'Support desk'. Add a `tickets` entity from\r\n> these sample rows, a dashboard screen with a KPI and a chart over tickets,\r\n> RBAC with an agent role that cannot delete, dark theme, then validate and\r\n> generate the app.\"\r\n\r\n## Step by step\r\n\r\n1. **Point it at a source.** A Drizzle schema file, or a handful of sample rows.\r\n2. **Introspect.** The agent calls `introspect_source` and shows you the drafted\r\n `EntitySchema` - field names, types, primary key, guessed formats.\r\n3. **Refine (optional).** Correct a type, mark a field hidden or read-only, add\r\n validation - in chat, or later in the [visual designer](./app-designer.md).\r\n4. **Scaffold.** The agent calls `scaffold_entity`; the files come back already\r\n run through the Svelte compiler.\r\n5. **Verify.** The agent runs your project's `svelte-check`; if anything fails it\r\n iterates. This is the loop that keeps AI output trustworthy.\r\n\r\n## Prompts that work\r\n\r\nFrom a Drizzle schema:\r\n\r\n> \"Using the svgrid MCP, build a CRUD screen for the `customers` table in\r\n> `src/lib/db/schema.ts`.\"\r\n\r\nFrom sample data, when there is no schema yet:\r\n\r\n> \"Here are five example rows of our invoices. Use the svgrid MCP to introspect a\r\n> schema, then scaffold a CRUD screen at `/invoices`.\"\r\n>\r\n> ```json\r\n> [{ \"id\": \"INV-1\", \"customer\": \"Acme\", \"amount\": 4200, \"paid\": true, \"due\": \"2026-07-01\" }]\r\n> ```\r\n\r\nRefining before you commit:\r\n\r\n> \"Show me the drafted schema first. Mark `internalNotes` hidden, make `email`\r\n> required, and set `status` to an enum of draft/sent/paid before scaffolding.\"\r\n\r\n## What comes back\r\n\r\n`scaffold_entity` writes three files (the same layout as the CLI and designer):\r\n\r\n```\r\nsrc/lib/customers.schema.ts # the EntitySchema + row type\r\nsrc/routes/api/customers/+server.ts # createKitHandlers data endpoint\r\nsrc/routes/customers/+page.svelte # the grid + edit-panel screen\r\n```\r\n\r\nEach carries `svgrid:managed` markers so a re-generation updates the managed\r\nregions and leaves your hand-written code untouched. See\r\n[code generation](./code-generation.md) for the anatomy of each file.\r\n\r\n## Bring your own key\r\n\r\nThe generator uses **your** agent's model and API key - your schema and data\r\nnever touch our servers. The MCP server itself makes no model calls; it provides\r\nintrospection + scaffolding + verification tools that the host agent drives.\r\n\r\n## Licensing\r\n\r\nGeneration is soft-gated: it runs unlicensed and prepends a one-line commercial\r\nnotice, and the generated app carries the usual watermark until you call\r\n`setLicenseKey()`. Set `SVGRID_LICENSE_KEY` in the MCP config to license it. See\r\n[licensing](../licensing.md#studio-data-app-generator).\r\n\r\n## See also\r\n\r\n- [The Studio CLI](./cli.md) - the deterministic, no-AI path\r\n- [Visual app designer](./app-designer.md) - refine an AI draft by hand before generating\r\n- [Code generation](./code-generation.md) - the anatomy of the emitted files\r\n- [MCP server](../../help/mcp-server.md) - full MCP reference\r\n"
2706
2718
  },
2707
2719
  {
2708
2720
  "slug": "enterprise/studio/api",
@@ -2714,7 +2726,7 @@ export const docs = [
2714
2726
  "slug": "enterprise/studio/app-designer",
2715
2727
  "path": "docs/enterprise/studio/app-designer.md",
2716
2728
  "title": "Visual app designer",
2717
- "markdown": "# Visual app designer\r\n\r\n`SvStudioDesigner` is the grid-centric visual **data-app** designer - compose a\r\nmulti-entity app by arranging data-bound blocks on a canvas, then generate a\r\nrunnable SvelteKit project. It's the app-level companion to the single-entity\r\n[schema designer](./designer.md): where that authors one `EntitySchema`, this\r\ncomposes **screens** across **many entities**.\r\n\r\nGrid-centric by design: the blocks are schema-driven and data-bound (a grid, a\r\nchart, a pivot, a dashboard, a KPI, master-detail, a faceted filter panel, a\r\nrecord panel, a lookup) - not arbitrary layout components. Point it at a\r\ndatabase, get a CRUD app - kept to data views.\r\n\r\n> **Just want to open it?** `npx @svgrid/studio designer` launches this designer\r\n> in your browser, auto-saves your work to `studio.config.json`, and writes the\r\n> generated app to a folder - no host app needed. See\r\n> [Launch the designer](./launch.md).\r\n\r\n## Starting a new app\r\n\r\n**New app** in the top bar walks you from nothing to a working CRUD app: pick\r\nwhere the data comes from, choose the tables, choose the pages, open the result.\r\n\r\n1. **Start** - sample data, your own data, or a blank set of tables you name.\r\n2. **Data** - connect a database (the table picker shows row counts and lets you\r\n preview rows before importing), read a **Supabase** project, pick a starter\r\n dataset, point at a REST endpoint, or paste an OpenAPI document.\r\n3. **Screens** - tick which pages each table gets (list, form, record page,\r\n dashboard) and how rows are edited: a popup form, in the grid, or on the\r\n record page.\r\n4. **Done** - name it and open it. It arrives as one undo step, so Ctrl+Z puts\r\n the previous design back.\r\n\r\nConnecting to a live SQL database needs the local designer\r\n(`npx @svgrid/studio dev`) because database drivers run on your machine, not in a\r\nbrowser tab. **Supabase is the exception**: it serves its own REST API, so the\r\nwizard reads your tables with just the project URL and the anon key - it is the\r\none real database that works from\r\n[svgrid.com/studio](https://svgrid.com/studio) with nothing installed. Row-level\r\nsecurity still applies, so the app sees exactly what the browser may see. The\r\nother paths work there too, and you can rebind to any database later with\r\n**Use my data**.\r\n\r\nThe terminal equivalent is [`svgrid-studio init`](./cli.md#init) - the same\r\ngenerator, the same app. The questions differ slightly: the CLI also asks for a\r\ntheme and light/dark, while the wizard offers a dashboard page per table (pick\r\nyour theme in the designer afterwards).\r\n\r\n## How the screen is laid out\r\n\r\nHere is the real designer with a small Sales App open:\r\n\r\n![The visual app designer: a Pages and Entities rail on the left, Design / Code tabs above a live grid preview (a customer grid with status chips) in the middle, and a screen properties panel on the right.](/docs-media/studio-app-designer.png)\r\n\r\nYou do not need to understand the internals to use it. The same layout, labelled:\r\n\r\n![The visual designer's layout: a screens list on the left, a palette of blocks to add, a live preview in the middle, a properties panel on the right, and a Generate app button in the top bar.](/docs-media/studio-designer-anatomy.svg)\r\n\r\n- **Screens** (far left) - the pages of your app. Click one to edit it; **+ Add\r\n screen** makes a new one.\r\n- **Blocks** - the pieces you drop onto a screen: a **grid** (a table of records),\r\n a **chart**, a **pivot**, a **KPI** number, a **dashboard**, a **filter panel**,\r\n and a **record panel**. Click or drag one onto the preview.\r\n- **Live preview** (middle) - your screen with **real data**, updating as you\r\n change things. What you see is what the app will look like.\r\n- **Properties** (right) - tune the selected block, or - with nothing selected -\r\n edit the entity's **fields** and pick its **data source**.\r\n- **Generate app** (top right) - when it looks right, one click writes the whole,\r\n runnable app.\r\n\r\nThe rest of this page is the detailed reference for each area, aimed at developers\r\nembedding or scripting the designer. If you just want to build an app, everything\r\nabove is done by pointing and clicking - see\r\n[Launch the designer](./launch.md).\r\n\r\n## What it edits: the project model\r\n\r\nThe designer reads and writes a `StudioProject` - the declarative model behind\r\nthe whole app:\r\n\r\n```ts\r\nimport { createProject } from '@svgrid/enterprise'\r\n\r\n// One default screen (grid + edit form) per entity, in-memory.\r\nlet project = $state(createProject([customerSchema, orderSchema], { title: 'Sales App' }))\r\n```\r\n\r\n```svelte {nocheck}\r\n<script lang=\"ts\">\r\n import { SvStudioDesigner } from '@svgrid/enterprise'\r\n</script>\r\n\r\n<SvStudioDesigner {project} onChange={(p) => (project = p)} />\r\n```\r\n\r\nThe designer is a single IDE-style frame: a **title bar** (app name + accent +\r\nundo/redo + Import/Load/Save/Generate), a **screen tab strip** (switch, close, or\r\nadd a screen), the three work panels, and a **status bar** (validity, entity /\r\nscreen / block counts, current selection, data source).\r\n\r\n- **Rail** (left) - switch and add screens; each screen is bound to an entity.\r\n- **Screen tabs** - the open screens as a document strip; click to switch, the\r\n **x** to remove one, the **+** to add one (from the current template).\r\n- **Palette** - the block kinds. **Drag** one onto the canvas to add it (or click).\r\n- **Canvas** - the screen's blocks in a responsive **12-column** grid, previewed\r\n live with the real components. **Drag a block** to reorder it; **drag its right\r\n edge** to set its width (1-12 columns), use the ⅓ / ½ / ⅔ / full quick buttons\r\n in its header, or the **Layout > Width** slider. **Drag a block's bottom edge**\r\n to make its region taller or shorter - grid, chart, pivot, and master-detail\r\n blocks are height-resizable, and the chosen height flows through to the generated app\r\n (also set it precisely under **Layout > Height** in the inspector).\r\n- **Inspector** (right) - edit the selected block: a grid's **editing mode** +\r\n behavior + **column config** (see below), a chart's group-by / measure / reduce\r\n / type. With no block selected, edit the **page** (title / route /\r\n **nav** settings), the entity's **data source** (see below), and the **entity's\r\n fields** - add, rename, retype, flag (PK / required / read-only), pick a\r\n **relation's target entity + label field**, and **drag to reorder**. The rail\r\n sets the **default source kind** for new entities, the **app layout**, and adds\r\n screens from a **template** (CRUD, dashboard, master-detail, empty).\r\n- **Top bar** - rename the app, set an **accent color** (themes the whole app),\r\n add a **New entity** from scratch, **Import CSV** (drop in a spreadsheet - see\r\n below), **Connect DB** (the launcher's live-database wizard - see\r\n [Launch the designer](./launch.md)), **Import schema** (paste a Drizzle / Prisma\r\n schema to add its entities), **Save / Load** the design as `studio.config.json`,\r\n and **Generate app**. With no entities yet, the canvas shows an **onboarding**\r\n screen offering the same ways to start.\r\n- **✨ Copilot** (when the host wires it) - describe a change in plain English\r\n (\"add an orders screen with a revenue chart\", \"make mrr required\") and the AI\r\n edits your project. It's a host hook: `<SvStudioDesigner onCopilot={...} />`\r\n receives `{ prompt, project }` and returns the edited `StudioProject` - your AI\r\n keys stay server-side. The result is validated before it applies, and it's one\r\n **Ctrl/Cmd+Z** away.\r\n\r\nThe three panels are **resizable** (drag the dividers). Every edit is **undoable**\r\n(Ctrl/Cmd+Z, Ctrl+Shift+Z / Ctrl+Y to redo); **Delete** removes the selected\r\nblock, **Ctrl/Cmd+D** (or the **⧉** header button) **duplicates** it, and\r\n**Escape** deselects. **Preview app** opens the whole app full-screen with a\r\n**Desktop / Tablet / Mobile** device-width toggle to check responsiveness. **Generate app** opens the output in a\r\n**file-tree viewer** modal (scrollable, Copy per file) and a **Download .zip** of\r\nthe **complete runnable SvelteKit + Vite project** - unzip, `npm install`,\r\n`npm run dev`. The zip includes `package.json` (with the right driver deps),\r\n`vite.config.ts`, `svelte.config.js`, `tsconfig.json`, the app shell, and every\r\ngenerated screen.\r\n\r\n## The grid (and how it edits)\r\n\r\nThe grid is the core block, so **editing is a grid property**, not a separate\r\nblock. Select a grid and set its **Editing mode**:\r\n\r\n- **Popup form** - double-click a row to edit it in a modal / drawer / inline\r\n panel (pick the **Form style**); a **+ New** button adds rows. This is the\r\n default.\r\n- **Inline** - edit cells right in the grid (Excel-style); each change saves via\r\n the data source.\r\n- **Read-only** - no editing.\r\n\r\nThe grid's property editor also covers **Behavior** (Sortable, Filtering + search,\r\nRow selection, Cell range selection, Striped rows, Totals footer row, Density),\r\n**Paging** (Paginate on / off, Page size, **Pager position** - bottom / top / both,\r\nand the **Page size options** for the selector), and per-**Column** settings -\r\nexpand a column to set its **header**, **width**, **alignment**, **pin** (left /\r\nright), plus show / hide + reorder. There is no standalone \"Edit form\" block - the\r\ngrid owns editing end to end.\r\n\r\n### Export toolbar\r\n\r\n**Export toolbar** adds a button bar above the grid. Six options, in two groups:\r\n\r\n| Button | Runs through | Adds to the generated app |\r\n| ------ | ------------ | ------------------------- |\r\n| Export CSV / Export JSON / Copy | the free grid API | nothing |\r\n| Export Excel (.xlsx) | `@svgrid/enterprise` | `jszip` |\r\n| Export PDF | `@svgrid/enterprise` | `pdfmake` |\r\n| Print | `@svgrid/enterprise` | nothing |\r\n\r\nThe Excel export is real OOXML - typed number and date cells, styled headers, a\r\nfrozen header row - not a renamed CSV. PDF is paginated with a repeating header,\r\nand Print opens the browser's print dialog on a paginated layout.\r\n\r\nAll six export what the user currently sees: the visible columns, in their\r\ncurrent order, over the filtered and sorted rows. The optional dependencies are\r\ndeclared only for the buttons you switch on, so a CSV-only app installs neither.\r\nThe canvas preview runs the same code the generated app does, so you can try a\r\nreal export before generating.\r\n\r\n## The analytical + companion blocks\r\n\r\nBeyond the grid, every block is still bound to the `EntitySchema` - these are data\r\nviews, not generic widgets:\r\n\r\n| Block | What it renders | Inspector |\r\n| --- | --- | --- |\r\n| **Chart** | A chart (`SvSchemaChart`) - bar, pie, line, area, radar, funnel, waterfall, or treemap. | Group-by dimension, measure, reduce, type. |\r\n| **Pivot** | A full pivot table (`SvPivotDesigner`) the end user can re-pivot live. | Row + column dimensions (checkboxes), a measure, and its aggregate. |\r\n| **Dashboard** | A schema-driven KPI + chart board (`SvSchemaDashboard`). | - |\r\n| **KPI** | A single reduced metric tile. | Label, measure, reduce. |\r\n| **Gauge** | A radial gauge (`SvGauge`) of one reduced measure within a range - utilization, progress, scores. | Label, measure, reduce, min / max, unit. |\r\n| **Tree** | A hierarchical tree (`SvTree`) built from the entity's own rows via a self-referential parent. | A label field + a parent field (a row's link to its parent row). |\r\n| **Tabs** | A tabbed container (`SvTabs`) that **groups display blocks** into tabs - e.g. an Overview tab of KPIs + a Details tab with a chart. | Add / rename / remove tabs; per tab, add child blocks (charts, KPIs, gauges, pivots, trees). |\r\n| **Accordion** | A collapsible-sections container (like Tabs, stacked vertically). | Add / rename / remove sections; child blocks per section. |\r\n| **Master / detail** | A row that expands into a nested grid of related records. | Child entity + foreign key. |\r\n| **Board** | A kanban board of the entity's rows, one lane per value of a group-by field, with drag between lanes. | Group-by, card title / subtitle / badge fields, open-screen drill. |\r\n| **Calendar** | A month event-calendar: each row with a date lands on its day, labelled and optionally color-coded. | Date field, title field, color field, open-screen drill. |\r\n| **Detail** | A full record \"detail page\": header, metric row, field sections, and related-record tabs - the 360 view a row action or drill-through opens. | Title / subtitle / status / metric fields, sections, related child entities. |\r\n| **Form** | A standalone create / edit form for the entity. | Presentation (drawer / modal / inline). |\r\n| **Filter panel** | A faceted sidebar that **filters the screen's grid** - enum / boolean facets pick a value, text facets search. | Title + which fields become facets. |\r\n| **Record panel** | Shows the row **selected in the grid** - a read-only field list, or an inline edit form. | Editable on / off, and (read-only) which fields to show. |\r\n| **Lookup** | Marks a relation field as a searchable picker in the edit form. | The relation field. |\r\n| **UI component** | A component from the SvGrid UI kit dropped from the toolbox - entity-agnostic, works on freestanding pages too. Grouped as Actions, Inputs, Display, Feedback, Layout, and Navigation, and covering headings and prose (heading, text, link, quote, code, keyboard key, list) as well as controls, pickers, and date/time inputs. | The component's own props (extracted from the component's own types, with its JSDoc as the tooltip), plus data bindings. |\r\n\r\nThe **filter** and **record** panels wire to the grid on the same screen: the\r\nfilter panel calls the grid controller's `setFilter`, and clicking a grid row\r\npublishes it to the record panel. So a common layout is a **filter panel + grid +\r\nrecord panel** three-up - list, narrow, inspect - all generated for you.\r\n\r\nThe grid, chart, pivot, and master-detail blocks are **height-resizable** (drag\r\nthe block's bottom edge, or set **Layout > Height**), and the chosen height flows\r\ninto the generated app. The filter and record panels size to their content.\r\n\r\n### Conditional formatting\r\n\r\nA grid's inspector has a **Conditional formatting** section: add no-code rules\r\nthat style a cell by its value - pick a field, a comparison (`=`, `<`, `>`,\r\n`contains`, `is empty`, ...), a value, and a **text color / fill / bold**. Rules\r\nrender **live in the canvas** and compile to the grid's built-in\r\n`conditionalFormats` rule engine in the generated app (e.g. negative `mrr` red,\r\n`status = overdue` filled). It's the same engine you'd use by hand - the designer\r\njust authors the rules.\r\n\r\n### Navigation & row actions\r\n\r\nThe grid's **Navigation & actions** section wires flow between screens:\r\n**drill-through** (row click opens another screen, filtered to the clicked value)\r\nand **row action buttons** (Edit / Delete / Open). A chart can drill too. See\r\n[Navigation & row actions](./navigation.md) for the full picture.\r\n\r\n## Data sources (per entity)\r\n\r\nEach entity binds to **its own backend** - the designer is not limited to one\r\ndata source per app. In the inspector (no block selected), the **Data source**\r\nsection binds the screen's entity to:\r\n\r\n- **In-memory** - seeded sample rows, runs with no backend (the default).\r\n- **Local database (no setup)** - a real, persistent Postgres ([PGlite](https://pglite.dev))\r\n running in the browser and saved to IndexedDB, so rows survive reloads with zero\r\n backend. Same SQL as production - swap to a hosted **SQL** source later without\r\n touching the schema. See [Local database](./local-database.md).\r\n The builder is a draggable, resizable, maximizable panel, and you can open it for\r\n any entity straight from the **Data model** dialog's **Configure** button.\r\n\r\n- **REST API** - a request builder: **method**, **base URL**, **path** (path\r\n params auto-derive from `{tokens}`), and **Query / Path / Header** tabs. **Send**\r\n runs it live and shows a **response table** plus the real rows in the canvas\r\n grid; **Import fields** rewrites the entity's schema to match the response keys.\r\n An **API format** picker (Manual / Offset + Limit / DummyJSON / json-server)\r\n wires a wire-format adapter so the grid does **real server-side paging and sort**;\r\n Manual keeps the rows-path / total-path mapping for a single fetched page.\r\n- **Supabase** - **List tables** reads your project's tables so you pick one, and\r\n **Import schema** pulls its real columns, primary key, foreign keys, and enums\r\n into the entity; **Preview** shows live rows (works in the online designer, since\r\n Supabase is HTTP). The project **URL + anon key are a shared connection** you set\r\n once for every Supabase entity. A **Live updates (Realtime)** toggle emits a live\r\n subscription. **Generate app** emits `createClient` in `connections.ts` reading\r\n `PUBLIC_SUPABASE_URL` / `PUBLIC_SUPABASE_ANON_KEY` from `.env` (the key is never\r\n inlined) and adds `@supabase/supabase-js`; access is protected by your RLS\r\n policies.\r\n- **SQL** - paste a connection string (the dialect is auto-detected) or fill the\r\n guided form, set the **Schema** (Postgres search path). In the **local** designer\r\n (`npx @svgrid/studio designer`) **Preview data** runs a real `SELECT` and shows\r\n your actual rows on the canvas, and a missing `pg` / `mysql2` / ... driver is a\r\n **one-click install**. **Generate app** emits a connected\r\n `src/routes/api/<table>/+server.ts` (the dialect's driver, reading\r\n `DATABASE_URL`) and points the grid at it; the driver dep is added for you. In\r\n the **online** designer, bind the entity and generate - the app connects for real\r\n at runtime. To scaffold from an existing DB via CLI: `npx @svgrid/studio add\r\n <table> --db <dialect> --url …`.\r\n\r\n**Generate app** then emits the matching adapter per entity in `src/lib/data.ts`\r\n(`createRestDataSource` / `createSqlDataSource` / `createSupabaseDataSource` /\r\n`createInMemoryDataSource`). SQL and Supabase entities read their connection from a\r\ngenerated `src/lib/connections.ts` (or the `+server.ts` route) - the SQL driver and\r\nthe Supabase client are wired for you; the only manual step is setting the\r\nconnection in `.env` (the bundle ships a `.env.example`). See\r\n[Databases](./databases.md) and [REST API](./rest-api.md).\r\n\r\n## Import a CSV / spreadsheet\r\n\r\nThe fastest way to start from **your own data**: **Import CSV** in the top bar\r\ntakes a `.csv` file and turns it into a running screen. The designer parses the\r\nfile (quoted fields, embedded newlines, and CRLF included), **infers a type per\r\ncolumn** from its values (number, boolean, date, or text - thousands separators\r\nand `yes/no/true/false` are understood), ensures a **primary key** (it reuses an\r\n`id` column or synthesizes one), and adds an entity with a full CRUD screen,\r\n**seeded with the real rows**. Unsafe headers (`First Name`, `E-mail`) become safe\r\nfield keys and the note tells you what was renamed.\r\n\r\nImported rows ship **in-memory** by default (no dependencies), so the app runs\r\nimmediately. Switch that entity's **Data source** to **Local database** to make\r\nthe same imported rows **persist** across reloads - the seed carries over. This is\r\nall client-side: `csvToEntity(name, text)` is a pure function exported from\r\n`@svgrid/enterprise`, so the same import works in the CLI and your own tools.\r\n\r\n> **Large files:** the imported rows are stored as the entity's seed, so they are\r\n> embedded in `studio.config.json` when you **Save** the design. That is fine for\r\n> reference data and samples; for a large dataset, import a representative sample\r\n> and point the entity at a **database** (Local database / SQL) for the full data.\r\n\r\n## Pages and layout\r\n\r\n- **Pages** - each screen is a route. In the inspector's **Page** section, toggle\r\n **Show in navigation**, set a **nav label** and **nav order**, or start a page\r\n from the **Empty** template. Hidden pages stay routable but drop out of the nav.\r\n- **Render mode** - eligible screens (a single plain grid, or read-only block\r\n screens, on a memory / SQL source) can switch from the default client page to\r\n **SSR**: an idiomatic `+page.server.ts` with `load` + form actions. The rules\r\n are in [Code generation](./code-generation.md#render-mode-spa-or-ssr-per-screen).\r\n- **App layout** - the rail's **App layout** section themes the generated shell:\r\n **Sidebar** or **Top navigation**, a **brand** name, a **company logo**\r\n (uploaded - stored inline and shown in the nav in place of the brand text), a\r\n **footer**, and (for the sidebar) the **nav position** (left / right). This\r\n drives the generated `src/routes/+layout.svelte`. The generated shell is\r\n **responsive**: on phones the sidebar collapses to a hamburger drawer, the\r\n top-nav links scroll, and each screen's block grid stacks to one column.\r\n\r\n## Save, regenerate, round-trip\r\n\r\nThe `StudioProject` is the persisted design. **Save config** exports a\r\n`studio.config.json`; regenerate the app from it any time:\r\n\r\n```bash\r\nnpm create @svgrid/studio@latest my-app -- --project ./studio.config.json\r\n```\r\n\r\nOr programmatically:\r\n\r\n```ts\r\nimport { serializeProject, parseProject, emitStudioProject } from '@svgrid/enterprise'\r\n\r\nconst json = serializeProject(project) // save the design\r\nconst project2 = parseProject(json) // reopen it\r\nconst files = emitStudioProject(project2) // -> the app's source files\r\n```\r\n\r\n**The exported app carries its own design.** The downloaded zip includes a\r\n`studio.config.json` at its root. To keep editing the app visually after you've\r\nworked on it locally, open the designer and **Load** that file - entities,\r\nscreens, blocks, theme, RBAC, i18n, and now the logo all come back exactly as\r\ngenerated. Because the designer regenerates the files under `src/`, keep any\r\nhand-written code in **new** files/modules you import, so a re-generate never\r\noverwrites it (or use the CLI's `svgrid:managed` markers - see\r\n[Code generation](./code-generation.md)).\r\n\r\n## Generate the app\r\n\r\n**Generate app** emits `src/lib/schemas.ts`, `src/lib/data.ts` (the right adapter\r\nper entity, plus `src/lib/connections.ts` when any entity is SQL / Supabase-bound),\r\nand one `src/routes/<route>/+page.svelte` **per screen** that composes that\r\nscreen's blocks with their config - a grid (with your visible columns, in order) +\r\nedit modal, plus any charts / pivots / dashboard / KPI tiles, and filter / record\r\npanels wired to the grid, all bound to the data - and the\r\nnav layout (sidebar or top-nav) + home. The pages are self-contained (they use\r\n`@svgrid/grid` + `@svgrid/enterprise` directly), so the output runs as a standard\r\nSvelteKit app.\r\n\r\n```ts\r\nimport { emitStudioProject } from '@svgrid/enterprise'\r\nconst files = emitStudioProject(project) // [{ path, contents, description }, ...]\r\n```\r\n\r\n## See also\r\n\r\n- [Code behind (Code view)](./code-behind.md) - write TypeScript against a typed `ctx` (grid API, events, `ctx.grid.sortable = true`, lifecycle)\r\n- [Sample apps + bind your data](./samples.md) - start from a ready-made app, then point it at your database\r\n- [Launch the designer](./launch.md) - `npx @svgrid/studio designer` (auto-save + generate to a folder)\r\n- [Schema designer](./designer.md) - author a single entity\r\n- [Dashboards](./dashboards.md) · [Databases](./databases.md) - the blocks + data sources\r\n- [CLI](./cli.md) / [Drizzle](./drizzle.md) / [Prisma](./prisma.md) - import a schema to design from\r\n"
2729
+ "markdown": "# Visual app designer\r\n\r\n`SvStudioDesigner` is the grid-centric visual **data-app** designer - compose a\r\nmulti-entity app by arranging data-bound blocks on a canvas, then generate a\r\nrunnable SvelteKit project. It's the app-level companion to the single-entity\r\n[schema designer](./designer.md): where that authors one `EntitySchema`, this\r\ncomposes **screens** across **many entities**.\r\n\r\nGrid-centric by design: the blocks are schema-driven and data-bound (a grid, a\r\nchart, a pivot, a dashboard, a KPI, master-detail, a faceted filter panel, a\r\nrecord panel, a lookup) - not arbitrary layout components. Point it at a\r\ndatabase, get a CRUD app - kept to data views.\r\n\r\n> **Just want to open it?** `npx @svgrid/studio designer` launches this designer\r\n> in your browser, auto-saves your work to `studio.config.json`, and writes the\r\n> generated app to a folder - no host app needed. See\r\n> [Launch the designer](./launch.md).\r\n\r\n## Starting a new app\r\n\r\n**New app** in the top bar walks you from nothing to a working CRUD app: pick\r\nwhere the data comes from, choose the tables, choose the pages, open the result.\r\n\r\n1. **Start** - sample data, your own data, or a blank set of tables you name.\r\n2. **Data** - connect a database (the table picker shows row counts and lets you\r\n preview rows before importing), read a **Supabase** project, pick a starter\r\n dataset, point at a REST endpoint, or paste an OpenAPI document.\r\n3. **Screens** - tick which pages each table gets (list, form, record page,\r\n dashboard) and how rows are edited: a popup form, in the grid, or on the\r\n record page.\r\n4. **Done** - name it and open it. It arrives as one undo step, so Ctrl+Z puts\r\n the previous design back.\r\n\r\nConnecting to a live SQL database needs the local designer\r\n(`npx @svgrid/studio dev`) because database drivers run on your machine, not in a\r\nbrowser tab. **Supabase is the exception**: it serves its own REST API, so the\r\nwizard reads your tables with just the project URL and the anon key - it is the\r\none real database that works from\r\n[svgrid.com/studio](https://svgrid.com/studio) with nothing installed. Row-level\r\nsecurity still applies, so the app sees exactly what the browser may see. The\r\nother paths work there too, and you can rebind to any database later with\r\n**Use my data**.\r\n\r\nThe terminal equivalent is [`svgrid-studio init`](./cli.md#init) - the same\r\ngenerator, the same app. The questions differ slightly: the CLI also asks for a\r\ntheme and light/dark, while the wizard offers a dashboard page per table (pick\r\nyour theme in the designer afterwards).\r\n\r\n## How the screen is laid out\r\n\r\nHere is the real designer with a small Sales App open:\r\n\r\n![The visual app designer: a Pages and Entities rail on the left, Design / Code tabs above a live grid preview (a customer grid with status chips) in the middle, and a screen properties panel on the right.](/docs-media/studio-app-designer.png)\r\n\r\nYou do not need to understand the internals to use it. The same layout, labelled:\r\n\r\n![The visual designer's layout: a screens list on the left, a palette of blocks to add, a live preview in the middle, a properties panel on the right, and a Generate app button in the top bar.](/docs-media/studio-designer-anatomy.svg)\r\n\r\n- **Screens** (far left) - the pages of your app. Click one to edit it; **+ Add\r\n screen** makes a new one.\r\n- **Blocks** - the pieces you drop onto a screen: a **grid** (a table of records),\r\n a **chart**, a **pivot**, a **KPI** number, a **dashboard**, a **filter panel**,\r\n and a **record panel**. Click or drag one onto the preview.\r\n- **Live preview** (middle) - your screen with **real data**, updating as you\r\n change things. What you see is what the app will look like.\r\n- **Properties** (right) - tune the selected block, or - with nothing selected -\r\n edit the entity's **fields** and pick its **data source**.\r\n- **Generate app** (top right) - when it looks right, one click writes the whole,\r\n runnable app.\r\n\r\nThe rest of this page is the detailed reference for each area, aimed at developers\r\nembedding or scripting the designer. If you just want to build an app, everything\r\nabove is done by pointing and clicking - see\r\n[Launch the designer](./launch.md).\r\n\r\n## What it edits: the project model\r\n\r\nThe designer reads and writes a `StudioProject` - the declarative model behind\r\nthe whole app:\r\n\r\n```ts\r\nimport { createProject } from '@svgrid/enterprise'\r\n\r\n// One default screen (grid + edit form) per entity, in-memory.\r\nlet project = $state(createProject([customerSchema, orderSchema], { title: 'Sales App' }))\r\n```\r\n\r\n```svelte {nocheck}\r\n<script lang=\"ts\">\r\n import { SvStudioDesigner } from '@svgrid/enterprise'\r\n</script>\r\n\r\n<SvStudioDesigner {project} onChange={(p) => (project = p)} />\r\n```\r\n\r\nThe designer is a single IDE-style frame: a **title bar** (app name + accent +\r\nundo/redo + Import/Load/Save/Generate), a **screen tab strip** (switch, close, or\r\nadd a screen), the three work panels, and a **status bar** (validity, entity /\r\nscreen / block counts, current selection, data source).\r\n\r\n- **Rail** (left) - switch and add screens; each screen is bound to an entity.\r\n- **Screen tabs** - the open screens as a document strip; click to switch, the\r\n **x** to remove one, the **+** to add one (from the current template).\r\n- **Palette** - the block kinds. **Drag** one onto the canvas to add it (or click).\r\n- **Canvas** - the screen's blocks in a responsive **12-column** grid, previewed\r\n live with the real components. **Drag a block** to reorder it; **drag its right\r\n edge** to set its width (1-12 columns), use the ⅓ / ½ / ⅔ / full quick buttons\r\n in its header, or the **Layout > Width** slider. **Drag a block's bottom edge**\r\n to make its region taller or shorter - grid, chart, pivot, and master-detail\r\n blocks are height-resizable, and the chosen height flows through to the generated app\r\n (also set it precisely under **Layout > Height** in the inspector).\r\n- **Inspector** (right) - edit the selected block: a grid's **editing mode** +\r\n behavior + **column config** (see below), a chart's group-by / measure / reduce\r\n / type. With no block selected, edit the **page** (title / route /\r\n **nav** settings), the entity's **data source** (see below), and the **entity's\r\n fields** - add, rename, retype, flag (PK / required / read-only), pick a\r\n **relation's target entity + label field**, and **drag to reorder**. The rail\r\n sets the **default source kind** for new entities, the **app layout**, and adds\r\n screens from a **template** (CRUD, dashboard, master-detail, empty).\r\n- **Top bar** - rename the app, set an **accent color** (themes the whole app),\r\n add a **New entity** from scratch, **Import CSV** (drop in a spreadsheet - see\r\n below), **Connect DB** (the launcher's live-database wizard - see\r\n [Launch the designer](./launch.md)), **Import schema** (paste a Drizzle / Prisma\r\n schema to add its entities), **Save / Load** the design as `studio.config.json`,\r\n and **Generate app**. With no entities yet, the canvas shows an **onboarding**\r\n screen offering the same ways to start.\r\n- **✨ Copilot** (when the host wires it) - describe a change in plain English\r\n (\"add an orders screen with a revenue chart\", \"make mrr required\") and the AI\r\n edits your project. It's a host hook: `<SvStudioDesigner onCopilot={...} />`\r\n receives `{ prompt, project }` and returns the edited `StudioProject` - your AI\r\n keys stay server-side. The result is validated before it applies, and it's one\r\n **Ctrl/Cmd+Z** away.\r\n\r\nThe three panels are **resizable** (drag the dividers). Every edit is **undoable**\r\n(Ctrl/Cmd+Z, Ctrl+Shift+Z / Ctrl+Y to redo); **Delete** removes the selected\r\nblock, **Ctrl/Cmd+D** (or the **⧉** header button) **duplicates** it, and\r\n**Escape** deselects. **Preview app** opens the whole app full-screen with a\r\n**Desktop / Tablet / Mobile** device-width toggle to check responsiveness. **Generate app** opens the output in a\r\n**file-tree viewer** modal (scrollable, Copy per file) and a **Download .zip** of\r\nthe **complete runnable SvelteKit + Vite project** - unzip, `npm install`,\r\n`npm run dev`. The zip includes `package.json` (with the right driver deps),\r\n`vite.config.ts`, `svelte.config.js`, `tsconfig.json`, the app shell, and every\r\ngenerated screen.\r\n\r\n## The grid (and how it edits)\r\n\r\nThe grid is the core block, so **editing is a grid property**, not a separate\r\nblock. Select a grid and set its **Editing mode**:\r\n\r\n- **Popup form** - double-click a row to edit it in a modal / drawer / inline\r\n panel (pick the **Form style**); a **+ New** button adds rows. This is the\r\n default.\r\n- **Inline** - edit cells right in the grid (Excel-style); each change saves via\r\n the data source.\r\n- **Read-only** - no editing.\r\n\r\nThe grid's property editor also covers **Behavior** (Sortable, Filtering + search,\r\nRow selection, Cell range selection, Striped rows, Totals footer row, Density),\r\n**Paging** (Paginate on / off, Page size, **Pager position** - bottom / top / both,\r\nand the **Page size options** for the selector), and per-**Column** settings -\r\nexpand a column to set its **header**, **width**, **alignment**, **pin** (left /\r\nright), plus show / hide + reorder.\r\n\r\n**Editing an existing record is a grid property**, not a block - the grid owns it\r\nend to end. The one thing a grid cannot give you is a form with no grid behind\r\nit, which is what the **Form** block is for (see below).\r\n\r\n### Form builder\r\n\r\n**Open form builder** - on the grid's **Form** tab, and on the entity's **Form\r\nlayout** section - opens the form in a room of its own. The canvas draws the form\r\nas it will look, with real labels, control shapes, and column spans, and you drag\r\nthe fields around on it directly. Click a field to rename it, change its control,\r\nadd help text, span it across the row, or give it a rule so it only appears (or\r\nonly becomes required, or locks) once another answer calls for it. **Group these\r\nfor me** sections an unarranged form in one click, and **Try it** swaps in the\r\nlive panel so you can type into the form and watch a condition fire.\r\n\r\nIt edits the *entity*, not the block, so the arrangement follows the entity\r\neverywhere it is rendered - including a server-rendered screen. See\r\n[edit forms](./edit-forms.md#building-one-without-writing-it).\r\n\r\n### Export toolbar\r\n\r\n**Export toolbar** adds a button bar above the grid. Six options, in two groups:\r\n\r\n| Button | Runs through | Adds to the generated app |\r\n| ------ | ------------ | ------------------------- |\r\n| Export CSV / Export JSON / Copy | the free grid API | nothing |\r\n| Export Excel (.xlsx) | `@svgrid/enterprise` | `jszip` |\r\n| Export PDF | `@svgrid/enterprise` | `pdfmake` |\r\n| Print | `@svgrid/enterprise` | nothing |\r\n\r\nThe Excel export is real OOXML - typed number and date cells, styled headers, a\r\nfrozen header row - not a renamed CSV. PDF is paginated with a repeating header,\r\nand Print opens the browser's print dialog on a paginated layout.\r\n\r\nAll six export what the user currently sees: the visible columns, in their\r\ncurrent order, over the filtered and sorted rows. The optional dependencies are\r\ndeclared only for the buttons you switch on, so a CSV-only app installs neither.\r\nThe canvas preview runs the same code the generated app does, so you can try a\r\nreal export before generating.\r\n\r\n## The analytical + companion blocks\r\n\r\nBeyond the grid, every block is still bound to the `EntitySchema` - these are data\r\nviews, not generic widgets:\r\n\r\n| Block | What it renders | Inspector |\r\n| --- | --- | --- |\r\n| **Form** | A standalone **create** form (`SvGridEditPanel`, inline and blank) - a \"New ticket\" page, an intake screen. Its fields, sections and rules come from the entity, so a form you designed once renders the same anywhere. | Heading, submit label, and what happens after saving: blank it for another entry, or go to a screen. Plus **Open form builder**. |\r\n| **Chart** | A chart (`SvSchemaChart`) - bar, pie, line, area, radar, funnel, waterfall, or treemap. | Group-by dimension, measure, reduce, type. |\r\n| **Pivot** | A full pivot table (`SvPivotDesigner`) the end user can re-pivot live. | Row + column dimensions (checkboxes), a measure, and its aggregate. |\r\n| **Dashboard** | A schema-driven KPI + chart board (`SvSchemaDashboard`). | - |\r\n| **KPI** | A single reduced metric tile. | Label, measure, reduce. |\r\n| **Gauge** | A radial gauge (`SvGauge`) of one reduced measure within a range - utilization, progress, scores. | Label, measure, reduce, min / max, unit. |\r\n| **Tree** | A hierarchical tree (`SvTree`) built from the entity's own rows via a self-referential parent. | A label field + a parent field (a row's link to its parent row). |\r\n| **Tabs** | A tabbed container (`SvTabs`) that **groups display blocks** into tabs - e.g. an Overview tab of KPIs + a Details tab with a chart. | Add / rename / remove tabs; per tab, add child blocks (charts, KPIs, gauges, pivots, trees). |\r\n| **Accordion** | A collapsible-sections container (like Tabs, stacked vertically). | Add / rename / remove sections; child blocks per section. |\r\n| **Master / detail** | A row that expands into a nested grid of related records. | Child entity + foreign key. |\r\n| **Board** | A kanban board of the entity's rows, one lane per value of a group-by field, with drag between lanes. | Group-by, card title / subtitle / badge fields, open-screen drill. |\r\n| **Calendar** | A month event-calendar: each row with a date lands on its day, labelled and optionally color-coded. | Date field, title field, color field, open-screen drill. |\r\n| **Detail** | A full record \"detail page\": header, metric row, field sections, and related-record tabs - the 360 view a row action or drill-through opens. | Title / subtitle / status / metric fields, sections, related child entities. |\r\n| **Form** | A standalone create / edit form for the entity. | Presentation (drawer / modal / inline). |\r\n| **Filter panel** | A faceted sidebar that **filters the screen's grid** - enum / boolean facets pick a value, text facets search. | Title + which fields become facets. |\r\n| **Record panel** | Shows the row **selected in the grid** - a read-only field list, or an inline edit form. | Editable on / off, and (read-only) which fields to show. |\r\n| **Lookup** | Marks a relation field as a searchable picker in the edit form. | The relation field. |\r\n| **UI component** | A component from the SvGrid UI kit dropped from the toolbox - entity-agnostic, works on freestanding pages too. Grouped as Actions, Inputs, Display, Feedback, Layout, and Navigation, and covering headings and prose (heading, text, link, quote, code, keyboard key, list) as well as controls, pickers, and date/time inputs. | The component's own props (extracted from the component's own types, with its JSDoc as the tooltip), plus data bindings. |\r\n\r\nThe **filter** and **record** panels wire to the grid on the same screen: the\r\nfilter panel calls the grid controller's `setFilter`, and clicking a grid row\r\npublishes it to the record panel. So a common layout is a **filter panel + grid +\r\nrecord panel** three-up - list, narrow, inspect - all generated for you.\r\n\r\nThe grid, chart, pivot, and master-detail blocks are **height-resizable** (drag\r\nthe block's bottom edge, or set **Layout > Height**), and the chosen height flows\r\ninto the generated app. The filter and record panels size to their content.\r\n\r\n### Conditional formatting\r\n\r\nA grid's inspector has a **Conditional formatting** section: add no-code rules\r\nthat style a cell by its value - pick a field, a comparison (`=`, `<`, `>`,\r\n`contains`, `is empty`, ...), a value, and a **text color / fill / bold**. Rules\r\nrender **live in the canvas** and compile to the grid's built-in\r\n`conditionalFormats` rule engine in the generated app (e.g. negative `mrr` red,\r\n`status = overdue` filled). It's the same engine you'd use by hand - the designer\r\njust authors the rules.\r\n\r\n### Navigation & row actions\r\n\r\nThe grid's **Navigation & actions** section wires flow between screens:\r\n**drill-through** (row click opens another screen, filtered to the clicked value)\r\nand **row action buttons** (Edit / Delete / Open). A chart can drill too. See\r\n[Navigation & row actions](./navigation.md) for the full picture.\r\n\r\n## Data sources (per entity)\r\n\r\nEach entity binds to **its own backend** - the designer is not limited to one\r\ndata source per app. In the inspector (no block selected), the **Data source**\r\nsection binds the screen's entity to:\r\n\r\n- **In-memory** - seeded sample rows, runs with no backend (the default).\r\n- **Local database (no setup)** - a real, persistent Postgres ([PGlite](https://pglite.dev))\r\n running in the browser and saved to IndexedDB, so rows survive reloads with zero\r\n backend. Same SQL as production - swap to a hosted **SQL** source later without\r\n touching the schema. See [Local database](./local-database.md).\r\n The builder is a draggable, resizable, maximizable panel, and you can open it for\r\n any entity straight from the **Data model** dialog's **Configure** button.\r\n\r\n- **REST API** - a request builder: **method**, **base URL**, **path** (path\r\n params auto-derive from `{tokens}`), and **Query / Path / Header** tabs. **Send**\r\n runs it live and shows a **response table** plus the real rows in the canvas\r\n grid; **Import fields** rewrites the entity's schema to match the response keys.\r\n An **API format** picker (Manual / Offset + Limit / DummyJSON / json-server)\r\n wires a wire-format adapter so the grid does **real server-side paging and sort**;\r\n Manual keeps the rows-path / total-path mapping for a single fetched page.\r\n- **Supabase** - **List tables** reads your project's tables so you pick one, and\r\n **Import schema** pulls its real columns, primary key, foreign keys, and enums\r\n into the entity; **Preview** shows live rows (works in the online designer, since\r\n Supabase is HTTP). The project **URL + anon key are a shared connection** you set\r\n once for every Supabase entity. A **Live updates (Realtime)** toggle emits a live\r\n subscription. **Generate app** emits `createClient` in `connections.ts` reading\r\n `PUBLIC_SUPABASE_URL` / `PUBLIC_SUPABASE_ANON_KEY` from `.env` (the key is never\r\n inlined) and adds `@supabase/supabase-js`; access is protected by your RLS\r\n policies.\r\n- **SQL** - paste a connection string (the dialect is auto-detected) or fill the\r\n guided form, set the **Schema** (Postgres search path). In the **local** designer\r\n (`npx @svgrid/studio designer`) **Preview data** runs a real `SELECT` and shows\r\n your actual rows on the canvas, and a missing `pg` / `mysql2` / ... driver is a\r\n **one-click install**. **Generate app** emits a connected\r\n `src/routes/api/<table>/+server.ts` (the dialect's driver, reading\r\n `DATABASE_URL`) and points the grid at it; the driver dep is added for you. In\r\n the **online** designer, bind the entity and generate - the app connects for real\r\n at runtime. To scaffold from an existing DB via CLI: `npx @svgrid/studio add\r\n <table> --db <dialect> --url …`.\r\n\r\n**Generate app** then emits the matching adapter per entity in `src/lib/data.ts`\r\n(`createRestDataSource` / `createSqlDataSource` / `createSupabaseDataSource` /\r\n`createInMemoryDataSource`). SQL and Supabase entities read their connection from a\r\ngenerated `src/lib/connections.ts` (or the `+server.ts` route) - the SQL driver and\r\nthe Supabase client are wired for you; the only manual step is setting the\r\nconnection in `.env` (the bundle ships a `.env.example`). See\r\n[Databases](./databases.md) and [REST API](./rest-api.md).\r\n\r\n## Import a CSV / spreadsheet\r\n\r\nThe fastest way to start from **your own data**: **Import CSV** in the top bar\r\ntakes a `.csv` file and turns it into a running screen. The designer parses the\r\nfile (quoted fields, embedded newlines, and CRLF included), **infers a type per\r\ncolumn** from its values (number, boolean, date, or text - thousands separators\r\nand `yes/no/true/false` are understood), ensures a **primary key** (it reuses an\r\n`id` column or synthesizes one), and adds an entity with a full CRUD screen,\r\n**seeded with the real rows**. Unsafe headers (`First Name`, `E-mail`) become safe\r\nfield keys and the note tells you what was renamed.\r\n\r\nImported rows ship **in-memory** by default (no dependencies), so the app runs\r\nimmediately. Switch that entity's **Data source** to **Local database** to make\r\nthe same imported rows **persist** across reloads - the seed carries over. This is\r\nall client-side: `csvToEntity(name, text)` is a pure function exported from\r\n`@svgrid/enterprise`, so the same import works in the CLI and your own tools.\r\n\r\n> **Large files:** the imported rows are stored as the entity's seed, so they are\r\n> embedded in `studio.config.json` when you **Save** the design. That is fine for\r\n> reference data and samples; for a large dataset, import a representative sample\r\n> and point the entity at a **database** (Local database / SQL) for the full data.\r\n\r\n## Pages and layout\r\n\r\n- **Pages** - each screen is a route. In the inspector's **Page** section, toggle\r\n **Show in navigation**, set a **nav label** and **nav order**, or start a page\r\n from the **Empty** template. Hidden pages stay routable but drop out of the nav.\r\n- **Render mode** - eligible screens (a single plain grid, or read-only block\r\n screens, on a memory / SQL source) can switch from the default client page to\r\n **SSR**: an idiomatic `+page.server.ts` with `load` + form actions. The rules\r\n are in [Code generation](./code-generation.md#render-mode-spa-or-ssr-per-screen).\r\n- **App layout** - the rail's **App layout** section themes the generated shell:\r\n **Sidebar** or **Top navigation**, a **brand** name, a **company logo**\r\n (uploaded - stored inline and shown in the nav in place of the brand text), a\r\n **footer**, and (for the sidebar) the **nav position** (left / right). This\r\n drives the generated `src/routes/+layout.svelte`. The generated shell is\r\n **responsive**: on phones the sidebar collapses to a hamburger drawer, the\r\n top-nav links scroll, and each screen's block grid stacks to one column.\r\n\r\n## Save, regenerate, round-trip\r\n\r\nThe `StudioProject` is the persisted design. **Save config** exports a\r\n`studio.config.json`; regenerate the app from it any time:\r\n\r\n```bash\r\nnpm create @svgrid/studio@latest my-app -- --project ./studio.config.json\r\n```\r\n\r\nOr programmatically:\r\n\r\n```ts\r\nimport { serializeProject, parseProject, emitStudioProject } from '@svgrid/enterprise'\r\n\r\nconst json = serializeProject(project) // save the design\r\nconst project2 = parseProject(json) // reopen it\r\nconst files = emitStudioProject(project2) // -> the app's source files\r\n```\r\n\r\n**The exported app carries its own design.** The downloaded zip includes a\r\n`studio.config.json` at its root. To keep editing the app visually after you've\r\nworked on it locally, open the designer and **Load** that file - entities,\r\nscreens, blocks, theme, RBAC, i18n, and now the logo all come back exactly as\r\ngenerated. Because the designer regenerates the files under `src/`, keep any\r\nhand-written code in **new** files/modules you import, so a re-generate never\r\noverwrites it (or use the CLI's `svgrid:managed` markers - see\r\n[Code generation](./code-generation.md)).\r\n\r\n## Generate the app\r\n\r\n**Generate app** emits `src/lib/schemas.ts`, `src/lib/data.ts` (the right adapter\r\nper entity, plus `src/lib/connections.ts` when any entity is SQL / Supabase-bound),\r\nand one `src/routes/<route>/+page.svelte` **per screen** that composes that\r\nscreen's blocks with their config - a grid (with your visible columns, in order) +\r\nedit modal, plus any charts / pivots / dashboard / KPI tiles, and filter / record\r\npanels wired to the grid, all bound to the data - and the\r\nnav layout (sidebar or top-nav) + home. The pages are self-contained (they use\r\n`@svgrid/grid` + `@svgrid/enterprise` directly), so the output runs as a standard\r\nSvelteKit app.\r\n\r\n```ts\r\nimport { emitStudioProject } from '@svgrid/enterprise'\r\nconst files = emitStudioProject(project) // [{ path, contents, description }, ...]\r\n```\r\n\r\n## See also\r\n\r\n- [Code behind (Code view)](./code-behind.md) - write TypeScript against a typed `ctx` (grid API, events, `ctx.grid.sortable = true`, lifecycle)\r\n- [Sample apps + bind your data](./samples.md) - start from a ready-made app, then point it at your database\r\n- [Launch the designer](./launch.md) - `npx @svgrid/studio designer` (auto-save + generate to a folder)\r\n- [Schema designer](./designer.md) - author a single entity\r\n- [Dashboards](./dashboards.md) · [Databases](./databases.md) - the blocks + data sources\r\n- [CLI](./cli.md) / [Drizzle](./drizzle.md) / [Prisma](./prisma.md) - import a schema to design from\r\n"
2718
2730
  },
2719
2731
  {
2720
2732
  "slug": "enterprise/studio/audit-log",
@@ -2804,7 +2816,7 @@ export const docs = [
2804
2816
  "slug": "enterprise/studio/edit-forms",
2805
2817
  "path": "docs/enterprise/studio/edit-forms.md",
2806
2818
  "title": "Edit forms & validation",
2807
- "markdown": "# Edit forms & validation\r\n\r\n`SvGridEditPanel` is the create / edit form for a row. It renders itself from an\r\n`EntitySchema`, validates input, and hands you a ready payload to save. It\r\npresents as a right-hand **drawer**, a centered **modal**, or **inline**, and\r\nfollows the grid's light / dark theme.\r\n\r\n![The create/edit modal with built-in validation - \"Email must be a valid email\".](/docs-media/studio-edit-modal.png)\r\n\r\n## Usage\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGridEditPanel } from '@svgrid/enterprise'\r\n let editing = $state<Customer | null | undefined>(undefined) // undefined = closed, null = create\r\n\r\n async function save({ mode, id, values }) {\r\n if (mode === 'create') await controller.createRow(values)\r\n else if (id) await controller.updateRow(id, values)\r\n editing = undefined\r\n }\r\n</script>\r\n\r\n{#if editing !== undefined}\r\n <SvGridEditPanel\r\n {schema}\r\n row={editing}\r\n presentation=\"modal\"\r\n onSubmit={save}\r\n onCancel={() => (editing = undefined)}\r\n />\r\n{/if}\r\n```\r\n\r\n## Props\r\n\r\n| Prop | Type | Description |\r\n| --- | --- | --- |\r\n| `schema` | `EntitySchema<TData>` | Drives the fields, validation, and payload. |\r\n| `row` | `TData \\| null` | Row to edit; `null` to create. |\r\n| `presentation` | `'drawer'` \\| `'modal'` \\| `'inline'` | Default `'drawer'` (right slide-over). |\r\n| `title` | `string` | Heading override. |\r\n| `submitLabel` | `string` | Save-button label override. |\r\n| `onSubmit` | `(payload) => void \\| Promise` | Called with a validated `{ mode, id, values }`. Throw to surface an error. |\r\n| `onCancel` | `() => void` | Called on cancel / close (Esc, backdrop, or the X). |\r\n\r\n## Presentation\r\n\r\n- **`drawer`** (default) - slides in from the right, full height.\r\n- **`modal`** - centered popup with a blurred backdrop.\r\n- **`inline`** - renders in the page flow (used by the designer preview).\r\n\r\nDrawer and modal animate in / out, close on **Esc** or backdrop click, and trap\r\nto a dialog role.\r\n\r\n## Validation\r\n\r\n**When it speaks up.** A field is checked when the user leaves it, not while they\r\nare still typing, so a form never scolds you for a value you have not finished\r\nentering. Once a field is showing an error it re-checks on every keystroke, so a\r\ncorrection clears the message straight away instead of making you submit again to\r\nfind out. A failed submit marks every field as visited, focuses the first one that\r\nneeds fixing, and lists them all in a summary at the top of the form that jumps to\r\na field when clicked.\r\n\r\nEach control carries `aria-invalid`, and its message (or its hint, when there is\r\nno error) is wired up with `aria-describedby`, so a screen reader announces the\r\nproblem with the field rather than leaving it to be discovered.\r\n\r\n**Closing a form with unsaved edits asks first.** Cancel, Escape, or a click on\r\nthe backdrop shows *Discard your changes?* in the footer, with **Keep editing**\r\nand **Discard**; a second Escape confirms. An untouched form closes immediately.\r\n\r\nThe save is blocked while anything fails. Three layers, in order:\r\n\r\n1. **Required** - non-empty for `required` fields.\r\n2. **Built-in constraints** - number validity + `min` / `max`,\r\n `minLength` / `maxLength`, `format: 'email' | 'url'`, and `pattern` (see\r\n [The EntitySchema](./schema.md#built-in-validation)).\r\n3. **Standard Schema** - any Zod / Valibot / ArkType validator on `field.validate`.\r\n\r\n```ts\r\n{ field: 'email', type: 'text', required: true, format: 'email' }\r\n{ field: 'mrr', type: 'number', min: 0 }\r\n{ field: 'name', type: 'text', minLength: 2, maxLength: 60 }\r\n```\r\n\r\nNo external library is required for the built-in rules - add a Standard Schema\r\nvalidator only when you need custom logic.\r\n\r\n### No-code rules\r\n\r\n`EntitySchema.validations` states cross-field rules as data, so the same rule\r\nruns in the form and in a generated app's server route:\r\n\r\n```ts\r\nvalidations: [\r\n { field: 'endsAt', op: 'gte', compareTo: 'startsAt', message: 'End must be after start' },\r\n { field: 'code', op: 'minLen', value: 4, message: 'Code needs 4+ characters' },\r\n]\r\n```\r\n\r\nOperators: `eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `required`, `minLen`, `maxLen`.\r\nUse `compareTo` to compare against another field instead of a fixed `value`.\r\n\r\n## Laying the form out\r\n\r\n`EntitySchema.form` says how the form is arranged. It lives on the schema, not on\r\nthe component, so a form you have *built* travels with the entity: it round-trips\r\nthrough `studio.config.json`, generates into an app, and draws the same in the\r\nedit panel and in a server-rendered form.\r\n\r\n```ts\r\nconst customers: EntitySchema = {\r\n name: 'customers',\r\n fields: [/* ... */],\r\n form: {\r\n columns: 2,\r\n sections: [\r\n { title: 'Contact', description: 'How we reach them.', fields: ['name', 'email', 'phone'] },\r\n { title: 'Billing', columns: 1, fields: ['plan', 'vatNumber'] },\r\n // A whole section can be conditional, the same way a field is.\r\n { title: 'Cancellation', fields: ['reason', 'notes'],\r\n visibleWhen: { kind: 'cmp', column: 'status', op: 'equals', value: 'cancelled' } },\r\n ],\r\n },\r\n}\r\n```\r\n\r\n- `fields` gives both the grouping and the order.\r\n- A field in no section still renders, in a trailing untitled group. A form never\r\n silently drops one.\r\n- A section whose fields are all hidden disappears with them, heading included.\r\n- `columns` on a section overrides the form's for that group alone; a field with\r\n `input.span = 2` spans the full width.\r\n\r\n`SvGridEditPanel`'s `columns` and `sections` props still win when passed, for a\r\none-off arrangement of an otherwise shared schema.\r\n\r\n**Server-rendered screens follow the same layout.** A screen with\r\n`renderMode: 'ssr'` renders its sections, descriptions and column counts, marks\r\n`span: 2` fields full-width, shows each field's hint, and states the field's own\r\nconstraints (`minlength`, `maxlength`, `min`, `max`, `pattern`, and an `email` /\r\n`url` input type) as native HTML attributes so the browser catches an obvious\r\nmistake before a round-trip. The action re-checks all of it server-side\r\nregardless, so the attributes save a trip but never decide anything.\r\n\r\n## Fields that react to the answers\r\n\r\nA field can appear, lock, or become required based on what the user has already\r\nentered - the form asks for a reason only when it needs one, and never asks\r\ntwice.\r\n\r\nEach condition is a `PredicateExpr`: **data, not a function**, so it survives a\r\nround-trip through `studio.config.json`, generates into an app unchanged, and can\r\nbe edited in a UI.\r\n\r\n```ts\r\n{\r\n field: 'otherReason',\r\n type: 'text',\r\n when: {\r\n // Only asked for - and only demanded - when the reason is \"other\".\r\n visible: { kind: 'cmp', column: 'reason', op: 'equals', value: 'other' },\r\n required: { kind: 'cmp', column: 'reason', op: 'equals', value: 'other' },\r\n },\r\n}\r\n{\r\n field: 'approver',\r\n type: 'text',\r\n // Locked until the order is big enough to need sign-off.\r\n when: { disabled: { kind: 'cmp', column: 'total', op: 'lessThan', value: 1000 } },\r\n}\r\n```\r\n\r\nThree rules make this safe to rely on:\r\n\r\n- A field hidden by `visible` is **skipped by validation** and **left out of the\r\n submitted payload**. It can never block a save the user cannot fix, and a value\r\n they can no longer see is never written back. The generated SSR route applies\r\n the same rule to a posted form, so both paths save the same fields.\r\n- `required` **replaces** the static `required` flag rather than adding to it, so\r\n a rule can make a normally required field optional as well as the reverse.\r\n- A malformed condition falls back to showing and enabling the field. A broken\r\n rule degrades to an ordinary form instead of hiding data.\r\n\r\nConditions are the same expressions the alert rules use, so `parsePredicate` and\r\nthe `SvExpressionEditor` component both work on them:\r\n\r\n```ts\r\nimport { parsePredicate } from '@svgrid/enterprise'\r\nwhen: { visible: parsePredicate('reason = \"other\"') }\r\n```\r\n\r\nA section whose fields are all hidden disappears along with them, heading\r\nincluded.\r\n\r\nThree [sample apps](./samples.md) ship this, so you can open one and watch it\r\nwork: **CRM** asks a lost deal what it lost to, **Support desk** demands a\r\nresolution before a ticket can be resolved or closed (and only then asks for a\r\nCSAT rating), and **Insurance Claims** requires a justification to deny a claim\r\nand freezes the amount and deductible once it has been paid.\r\n\r\n## Controls\r\n\r\nThe form renders each field with a control from the **editor suite**, not a bare\r\nnative input: numbers use `SvNumberInput` (spinners, min/max/step), booleans a\r\n`SvSwitchButton`, colors `SvColorInput`, passwords `SvPasswordInput` (strength\r\nmeter), ratings a `SvSlider`, dates and date-times a `SvDateTimePicker` (masked\r\ninput + calendar dropdown), enums a themed **dropdown** (`SvGridDropdown`), and\r\nJSON a textarea. The default follows the field type; override per field with\r\n`input.editorType`.\r\n\r\nBeyond the grid's cell editors, the form also offers a few **form-only** controls\r\nvia `input.editorType`: `phone` (`SvPhoneInput`), `country` (`SvCountryInput`),\r\n`mask` (`SvMaskedInput`, with an `input.mask` pattern like `'(999) 000-0000'`),\r\nand `slider`. In the [visual designer](./app-designer.md) each field has a\r\n**Control** picker (scoped to what fits its type) plus a **Wide** toggle\r\n(`input.span = 2`), so you pick the editor without touching code.\r\n\r\n```ts\r\n{ field: 'mrr', type: 'number', input: { editorType: 'slider' } }\r\n{ field: 'brand', type: 'text', input: { editorType: 'color' } }\r\n{ field: 'phone', type: 'text', input: { editorType: 'phone' } }\r\n{ field: 'ssn', type: 'text', input: { editorType: 'mask', mask: '999-99-9999' } }\r\n```\r\n\r\nForm-only editors degrade to a safe in-cell editor when the same field shows in a\r\ngrid (`slider` → number, `phone`/`country`/`mask` → text), so columns stay valid.\r\n\r\n**File / image upload.** Give a field an `upload` config and it renders\r\n`SvFileInput` (a picker with an image preview). With no handler it stores an\r\ninline data URL (no backend needed); pass an `uploads` handler that pushes to\r\nstorage and returns the URL:\r\n\r\n```svelte\r\n{ field: 'avatar', type: 'text', upload: { image: true, accept: 'image/*' } }\r\n\r\n<SvGridEditPanel {schema} row={editing}\r\n uploads={{ avatar: async (file) => await putToStorage(file) }} onSubmit={save} />\r\n```\r\n\r\n**Cascading (dependent) fields.** Compute a field's options from the current\r\nvalues with `dependentOptions` - the field clears when it stops being valid\r\n(e.g. City depends on Country):\r\n\r\n```svelte\r\n<SvGridEditPanel {schema} row={editing}\r\n dependentOptions={{ city: (values) => citiesByCountry[values.country] ?? [] }}\r\n onSubmit={save} />\r\n```\r\n\r\nSee the [rich fields demo](https://svgrid.com/demos/198-studio-form-fields/).\r\n\r\nEnum and `relation` fields use a custom dropdown whose panel **portals to\r\n`document.body`** (position: fixed), so it opens *above* a drawer or modal and\r\nnever grows the form (no scrollbar) - unlike a native `<select>` or an in-flow\r\npopup.\r\n\r\n## The modal is a movable window\r\n\r\nWith `presentation=\"modal\"`, the panel is a floating window: **drag** it by the\r\nheader, **resize** it from its edges (the content resizes with it), **maximize /\r\nrestore**, and **pin** it to any edge (left / top / bottom / right) - handy for\r\nkeeping the form docked beside the grid while you work. Pin again to unpin.\r\n\r\nSet **`persistKey`** to remember the window layout (pin / size / maximized) in\r\n`localStorage`, so it reopens where the user left it:\r\n\r\n```svelte\r\n<SvGridEditPanel {schema} row={editing} presentation=\"modal\" persistKey=\"customers\" ... />\r\n```\r\n\r\nOpen the editor on **double-click** (`onRowDoubleClick`), not single-click, so a\r\nclick can still select or interact with a row without popping the form.\r\n\r\n## See also\r\n\r\n- [The EntitySchema](./schema.md) - fields + validation constraints\r\n- [Master-detail](./master-detail.md) · [Data binding](./data-binding.md)\r\n"
2819
+ "markdown": "# Edit forms & validation\r\n\r\n`SvGridEditPanel` is the create / edit form for a row. It renders itself from an\r\n`EntitySchema`, validates input, and hands you a ready payload to save. It\r\npresents as a right-hand **drawer**, a centered **modal**, or **inline**, and\r\nfollows the grid's light / dark theme.\r\n\r\n![The create/edit modal with built-in validation - \"Email must be a valid email\".](/docs-media/studio-edit-modal.png)\r\n\r\n## Usage\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGridEditPanel } from '@svgrid/enterprise'\r\n let editing = $state<Customer | null | undefined>(undefined) // undefined = closed, null = create\r\n\r\n async function save({ mode, id, values }) {\r\n if (mode === 'create') await controller.createRow(values)\r\n else if (id) await controller.updateRow(id, values)\r\n editing = undefined\r\n }\r\n</script>\r\n\r\n{#if editing !== undefined}\r\n <SvGridEditPanel\r\n {schema}\r\n row={editing}\r\n presentation=\"modal\"\r\n onSubmit={save}\r\n onCancel={() => (editing = undefined)}\r\n />\r\n{/if}\r\n```\r\n\r\n## Props\r\n\r\n| Prop | Type | Description |\r\n| --- | --- | --- |\r\n| `schema` | `EntitySchema<TData>` | Drives the fields, validation, and payload. |\r\n| `row` | `TData \\| null` | Row to edit; `null` to create. |\r\n| `presentation` | `'drawer'` \\| `'modal'` \\| `'inline'` | Default `'drawer'` (right slide-over). |\r\n| `title` | `string` | Heading override. |\r\n| `submitLabel` | `string` | Save-button label override. |\r\n| `onSubmit` | `(payload) => void \\| Promise` | Called with a validated `{ mode, id, values }`. Throw to surface an error. |\r\n| `onCancel` | `() => void` | Called on cancel / close (Esc, backdrop, or the X). |\r\n\r\n## Presentation\r\n\r\n- **`drawer`** (default) - slides in from the right, full height.\r\n- **`modal`** - centered popup with a blurred backdrop.\r\n- **`inline`** - renders in the page flow (used by the designer preview).\r\n\r\nDrawer and modal animate in / out, close on **Esc** or backdrop click, and trap\r\nto a dialog role.\r\n\r\n## Validation\r\n\r\n**When it speaks up.** A field is checked when the user leaves it, not while they\r\nare still typing, so a form never scolds you for a value you have not finished\r\nentering. Once a field is showing an error it re-checks on every keystroke, so a\r\ncorrection clears the message straight away instead of making you submit again to\r\nfind out. A failed submit marks every field as visited, focuses the first one that\r\nneeds fixing, and lists them all in a summary at the top of the form that jumps to\r\na field when clicked.\r\n\r\nEach control carries `aria-invalid`, and its message (or its hint, when there is\r\nno error) is wired up with `aria-describedby`, so a screen reader announces the\r\nproblem with the field rather than leaving it to be discovered.\r\n\r\n**Closing a form with unsaved edits asks first.** Cancel, Escape, or a click on\r\nthe backdrop shows *Discard your changes?* in the footer, with **Keep editing**\r\nand **Discard**; a second Escape confirms. An untouched form closes immediately.\r\n\r\nThe save is blocked while anything fails. Three layers, in order:\r\n\r\n1. **Required** - non-empty for `required` fields.\r\n2. **Built-in constraints** - number validity + `min` / `max`,\r\n `minLength` / `maxLength`, `format: 'email' | 'url'`, and `pattern` (see\r\n [The EntitySchema](./schema.md#built-in-validation)).\r\n3. **Standard Schema** - any Zod / Valibot / ArkType validator on `field.validate`.\r\n\r\n```ts\r\n{ field: 'email', type: 'text', required: true, format: 'email' }\r\n{ field: 'mrr', type: 'number', min: 0 }\r\n{ field: 'name', type: 'text', minLength: 2, maxLength: 60 }\r\n```\r\n\r\nNo external library is required for the built-in rules - add a Standard Schema\r\nvalidator only when you need custom logic.\r\n\r\n### No-code rules\r\n\r\n`EntitySchema.validations` states cross-field rules as data, so the same rule\r\nruns in the form and in a generated app's server route:\r\n\r\n```ts\r\nvalidations: [\r\n { field: 'endsAt', op: 'gte', compareTo: 'startsAt', message: 'End must be after start' },\r\n { field: 'code', op: 'minLen', value: 4, message: 'Code needs 4+ characters' },\r\n]\r\n```\r\n\r\nOperators: `eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `required`, `minLen`, `maxLen`.\r\nUse `compareTo` to compare against another field instead of a fixed `value`.\r\n\r\n## Where a form comes from\r\n\r\nThree places, and which one you want depends on the record:\r\n\r\n| You want to | Use |\r\n| --- | --- |\r\n| Edit an existing row from a list | A **grid** with **Editing mode: Popup form**. Double-click a row. |\r\n| Edit whichever row is selected on the screen | A **record panel** with editing on - inline, drawer, or modal. |\r\n| Create a new record, with no grid behind it | A **Form** block, dragged from the Components rail. |\r\n\r\nAll three render the same `SvGridEditPanel` against the same `EntitySchema.form`,\r\nso a form designed once looks the same in every one of them.\r\n\r\nThe **Form** block is create-only by design. It is blank on load, submits, and\r\ncreates a row; after saving it either blanks itself for the next entry (the\r\ndefault, with a \"Saved\" confirmation) or opens another screen. Give it a heading\r\nand a submit label in the inspector - \"Report a problem\" / \"Send it\" reads better\r\nthan \"New Ticket\" / \"Create\" on a page somebody was sent to.\r\n\r\nAn inline form **fills whatever it is placed in**, so a form block is as wide as\r\nits block. Set the block's **Width** to *Narrow* or *Wide* when a full-width form\r\nis more than a reader wants to cross; in code that is `SvGridEditPanel`'s\r\n`formSize`.\r\n\r\n## Building one without writing it\r\n\r\nEverything below this line is authorable in the [visual designer](./app-designer.md).\r\nSelect the entity and open **Form layout -> Open form builder** (the grid block's\r\n**Form** tab has the same button). It edits the entity, so what you build there is\r\nwhat a generated app and a server-rendered screen render.\r\n\r\nOnce it is open the entity name in the title is a picker, so you can build any\r\nentity's form without going back out to find a page that uses it.\r\n\r\n**The canvas is the form.** It draws real labels, real control shapes, and the\r\nreal column grid, so arranging the form and looking at it are the same act - a\r\nfield you span shows as spanned, a `textarea` is tall, a switch is small.\r\n\r\n- **Drag a field** anywhere on the canvas, or select one and use ↑ / ↓ (announced\r\n for screen readers). Fields no section claims sit in a trailing **Not in a\r\n section** group, because that is exactly where the form puts them.\r\n- **Sections** take a title, a line of guidance, and their own column count.\r\n Hover one for its tools: columns, **Rule** (show the whole section only when a\r\n condition holds), **Fold**, reorder, and remove - which removes the heading\r\n only, never the fields under it. On an unarranged form, **Group these for me**\r\n proposes a grouping from the field names; it only appears when there is a real\r\n grouping to make.\r\n- **Click a field** and the right pane fills, in two tabs. **Field**: label,\r\n control, an `enum`'s choices, default value, placeholder, help text, span the\r\n full row, always required, and *Remove from this form*. Controls that need more\r\n say so - a mask gets its pattern, a number or slider gets its range, step,\r\n decimals and affixes - and nothing else is shown, so picking a control never\r\n leaves you with nowhere to configure it. **Rules**: the *Shown when* /\r\n *Required when* / *Locked when* conditions plus the cross-field checks that\r\n blame this field - the same `validations` rules, edited here because they are\r\n form logic. The tab carries a count, and so does the field on the canvas.\r\n- **Ctrl-click** adds a field to the selection and **Shift-click** takes a range\r\n within one lane; dragging any of them moves the whole set together, as does\r\n ↑ / ↓. With more than one selected the right pane offers what is genuinely\r\n bulk - move them all to a section, or remove them all - because the Field and\r\n Rules editors are single-field by nature. Escape drops back to one.\r\n- **+ Add field** builds a form from nothing without leaving for the schema\r\n inspector. Name and type up front; a name already on the entity is refused.\r\n Each section has its own **+ Field** that drops straight into it.\r\n- **Remove from this form is reversible.** Removed fields collect in a **Hidden\r\n from this form** tray under the canvas, and *Restore* puts one back where it\r\n was - removing never edited the section, only the field's visibility.\r\n- **Show rules** (on whenever the form has any) plays the conditions against the\r\n sample record right on the canvas: a field a rule hides goes dim with a\r\n *hidden* badge, a locked one gets a padlock, and a rule-required one gets the\r\n asterisk. Dimmed fields stay selectable and draggable - you are arranging the\r\n design, not the record. **Existing** / **New** switches which record you are\r\n simulating.\r\n- **Width** draws the form at the size it will really have. The window itself\r\n drags by its header, resizes from its corner, and maximizes on a double-click.\r\n\r\n### Folding a long form\r\n\r\n**Fold** on a section cycles through three states: not foldable, foldable, and\r\nfoldable-and-starts-folded. A foldable section's heading becomes a disclosure\r\nbutton carrying a count, so a long form opens at a readable length instead of a\r\nwall of inputs.\r\n\r\nFolding is a **display state, not a condition**. The fields are still filled in\r\nand still validated - use `visibleWhen` when you actually want them gone. If a\r\nfolded section holds an error the form opens it, so a rejected submit can never\r\npoint at something the user cannot see.\r\n\r\nServer-rendered screens get the same thing as a native `<details>`, so it folds\r\nwith JavaScript off, and a section that starts folded opens itself when the\r\nserver sends back an error for one of its fields.\r\n\r\n```ts\r\nsections: [\r\n { title: 'Contact', fields: ['name', 'email'] },\r\n { title: 'Billing', fields: ['vatNumber', 'poNumber'], collapsible: true, collapsed: true },\r\n]\r\n```\r\n\r\n### Asking one step at a time\r\n\r\n`form.steps` turns the sections into a wizard - **Ask one step at a time** in the\r\nbuilder. Each section is a step, so the sections *are* the design; there is no\r\nsecond list to keep in sync.\r\n\r\n```ts\r\nform: {\r\n steps: true,\r\n sections: [\r\n { title: 'Who', fields: ['name', 'email'] },\r\n { title: 'Company', fields: ['company', 'role'] },\r\n { title: 'Billing', fields: ['vatNumber'] },\r\n ],\r\n}\r\n```\r\n\r\nFour things make it behave:\r\n\r\n- **Next validates only the step you are on**, so a long form fails early and\r\n locally instead of dumping every error at the end. Back never validates -\r\n going backwards is always allowed.\r\n- A section hidden by `visibleWhen` is **skipped**, so the step count follows the\r\n answers rather than showing an empty step.\r\n- Fields in no section **join the last step** rather than becoming an untitled\r\n one of their own. Every step should be deliberate.\r\n- A submit that fails on an earlier step **jumps back to it**, so the focus never\r\n lands off-screen.\r\n\r\nOne section is a page, not a one-step wizard, and `collapsible` is ignored while\r\nstepping - a step is already one group at a time.\r\n\r\n**Server-rendered screens render the steps as ordinary sections.** Stepping\r\nthrough a `<form>` without JavaScript would mean a round-trip per step and\r\nsomewhere to hold the half-finished record. The server validates everything\r\neither way.\r\n\r\n**Try it** swaps the canvas for the live edit panel, so you can type into the form\r\nand watch a condition fire. Toggle **Existing** / **New**: the values differ, so\r\nthe conditions do too.\r\n\r\nNote that a grid block can still override the arrangement for one screen. When it\r\ndoes, the builder says so and offers to drop the override.\r\n\r\n### From an agent\r\n\r\nTwo MCP tools drive the same model, so an agent can build the form too:\r\n\r\n| Tool | What it does |\r\n| --- | --- |\r\n| `studio_set_form_layout` | Set the column count and the sections. Pass `\"suggest\": true` instead of `sections` to have them proposed from the field names. The reply reports what actually landed, including anything that fell through to the trailing group. |\r\n| `studio_set_field_conditions` | Set a field's `visible` / `required` / `disabled` conditions. A condition you do not name is left alone; pass `null` to clear one. |\r\n\r\nSee the [MCP server](./ai-generation.md) for the rest of the `studio_*` tools.\r\n\r\n## Laying the form out\r\n\r\n`EntitySchema.form` says how the form is arranged. It lives on the schema, not on\r\nthe component, so a form you have *built* travels with the entity: it round-trips\r\nthrough `studio.config.json`, generates into an app, and draws the same in the\r\nedit panel and in a server-rendered form.\r\n\r\n```ts\r\nconst customers: EntitySchema = {\r\n name: 'customers',\r\n fields: [/* ... */],\r\n form: {\r\n columns: 2,\r\n sections: [\r\n { title: 'Contact', description: 'How we reach them.', fields: ['name', 'email', 'phone'] },\r\n { title: 'Billing', columns: 1, fields: ['plan', 'vatNumber'] },\r\n // A whole section can be conditional, the same way a field is.\r\n { title: 'Cancellation', fields: ['reason', 'notes'],\r\n visibleWhen: { kind: 'cmp', column: 'status', op: 'equals', value: 'cancelled' } },\r\n ],\r\n },\r\n}\r\n```\r\n\r\n- `fields` gives both the grouping and the order.\r\n- A field in no section still renders, in a trailing untitled group. A form never\r\n silently drops one.\r\n- A section whose fields are all hidden disappears with them, heading included.\r\n- `columns` on a section overrides the form's for that group alone; a field with\r\n `input.span = 2` spans the full width.\r\n\r\n`SvGridEditPanel`'s `columns` and `sections` props still win when passed, for a\r\none-off arrangement of an otherwise shared schema.\r\n\r\n**Server-rendered screens follow the same layout.** A screen with\r\n`renderMode: 'ssr'` renders its sections, descriptions and column counts, marks\r\n`span: 2` fields full-width, shows each field's hint, and states the field's own\r\nconstraints (`minlength`, `maxlength`, `min`, `max`, `pattern`, and an `email` /\r\n`url` input type) as native HTML attributes so the browser catches an obvious\r\nmistake before a round-trip. The action re-checks all of it server-side\r\nregardless, so the attributes save a trip but never decide anything.\r\n\r\n## Fields that react to the answers\r\n\r\nA field can appear, lock, or become required based on what the user has already\r\nentered - the form asks for a reason only when it needs one, and never asks\r\ntwice.\r\n\r\nEach condition is a `PredicateExpr`: **data, not a function**, so it survives a\r\nround-trip through `studio.config.json`, generates into an app unchanged, and can\r\nbe edited in a UI.\r\n\r\n```ts\r\n{\r\n field: 'otherReason',\r\n type: 'text',\r\n when: {\r\n // Only asked for - and only demanded - when the reason is \"other\".\r\n visible: { kind: 'cmp', column: 'reason', op: 'equals', value: 'other' },\r\n required: { kind: 'cmp', column: 'reason', op: 'equals', value: 'other' },\r\n },\r\n}\r\n{\r\n field: 'approver',\r\n type: 'text',\r\n // Locked until the order is big enough to need sign-off.\r\n when: { disabled: { kind: 'cmp', column: 'total', op: 'lessThan', value: 1000 } },\r\n}\r\n```\r\n\r\nThree rules make this safe to rely on:\r\n\r\n- A field hidden by `visible` is **skipped by validation** and **left out of the\r\n submitted payload**. It can never block a save the user cannot fix, and a value\r\n they can no longer see is never written back. The generated SSR route applies\r\n the same rule to a posted form, so both paths save the same fields.\r\n- `required` **replaces** the static `required` flag rather than adding to it, so\r\n a rule can make a normally required field optional as well as the reverse.\r\n- A malformed condition falls back to showing and enabling the field. A broken\r\n rule degrades to an ordinary form instead of hiding data.\r\n\r\nConditions are the same expressions the alert rules use, so `parsePredicate` and\r\nthe `SvExpressionEditor` component both work on them:\r\n\r\n```ts\r\nimport { parsePredicate } from '@svgrid/enterprise'\r\nwhen: { visible: parsePredicate('reason = \"other\"') }\r\n```\r\n\r\nA section whose fields are all hidden disappears along with them, heading\r\nincluded.\r\n\r\nThree [sample apps](./samples.md) ship this, so you can open one and watch it\r\nwork: **CRM** asks a lost deal what it lost to, **Support desk** demands a\r\nresolution before a ticket can be resolved or closed (and only then asks for a\r\nCSAT rating), and **Insurance Claims** requires a justification to deny a claim\r\nand freezes the amount and deductible once it has been paid.\r\n\r\n## Controls\r\n\r\nThe form renders each field with a control from the **editor suite**, not a bare\r\nnative input: numbers use `SvNumberInput` (spinners, min/max/step), booleans a\r\n`SvSwitchButton`, colors `SvColorInput`, passwords `SvPasswordInput` (strength\r\nmeter), ratings a `SvSlider`, dates and date-times a `SvDateTimePicker` (masked\r\ninput + calendar dropdown), enums a themed **dropdown** (`SvGridDropdown`), and\r\nJSON a textarea. The default follows the field type; override per field with\r\n`input.editorType`.\r\n\r\nBeyond the grid's cell editors, the form also offers a few **form-only** controls\r\nvia `input.editorType`: `phone` (`SvPhoneInput`), `country` (`SvCountryInput`),\r\n`mask` (`SvMaskedInput`, with an `input.mask` pattern like `'(999) 000-0000'`),\r\nand `slider`. In the [visual designer](./app-designer.md) each field has a\r\n**Control** picker (scoped to what fits its type) plus a **Wide** toggle\r\n(`input.span = 2`), so you pick the editor without touching code.\r\n\r\n```ts\r\n{ field: 'mrr', type: 'number', input: { editorType: 'slider' } }\r\n{ field: 'brand', type: 'text', input: { editorType: 'color' } }\r\n{ field: 'phone', type: 'text', input: { editorType: 'phone' } }\r\n{ field: 'ssn', type: 'text', input: { editorType: 'mask', mask: '999-99-9999' } }\r\n```\r\n\r\nForm-only editors degrade to a safe in-cell editor when the same field shows in a\r\ngrid (`slider` → number, `phone`/`country`/`mask` → text), so columns stay valid.\r\n\r\n**File / image upload.** Give a field an `upload` config and it renders\r\n`SvFileInput` (a picker with an image preview). With no handler it stores an\r\ninline data URL (no backend needed); pass an `uploads` handler that pushes to\r\nstorage and returns the URL:\r\n\r\n```svelte\r\n{ field: 'avatar', type: 'text', upload: { image: true, accept: 'image/*' } }\r\n\r\n<SvGridEditPanel {schema} row={editing}\r\n uploads={{ avatar: async (file) => await putToStorage(file) }} onSubmit={save} />\r\n```\r\n\r\n**Cascading (dependent) fields.** Compute a field's options from the current\r\nvalues with `dependentOptions` - the field clears when it stops being valid\r\n(e.g. City depends on Country):\r\n\r\n```svelte\r\n<SvGridEditPanel {schema} row={editing}\r\n dependentOptions={{ city: (values) => citiesByCountry[values.country] ?? [] }}\r\n onSubmit={save} />\r\n```\r\n\r\nSee the [rich fields demo](https://svgrid.com/demos/198-studio-form-fields/).\r\n\r\nEnum and `relation` fields use a custom dropdown whose panel **portals to\r\n`document.body`** (position: fixed), so it opens *above* a drawer or modal and\r\nnever grows the form (no scrollbar) - unlike a native `<select>` or an in-flow\r\npopup.\r\n\r\n## The modal is a movable window\r\n\r\nWith `presentation=\"modal\"`, the panel is a floating window: **drag** it by the\r\nheader, **resize** it from its edges (the content resizes with it), **maximize /\r\nrestore**, and **pin** it to any edge (left / top / bottom / right) - handy for\r\nkeeping the form docked beside the grid while you work. Pin again to unpin.\r\n\r\nSet **`persistKey`** to remember the window layout (pin / size / maximized) in\r\n`localStorage`, so it reopens where the user left it:\r\n\r\n```svelte\r\n<SvGridEditPanel {schema} row={editing} presentation=\"modal\" persistKey=\"customers\" ... />\r\n```\r\n\r\nOpen the editor on **double-click** (`onRowDoubleClick`), not single-click, so a\r\nclick can still select or interact with a row without popping the form.\r\n\r\n## See also\r\n\r\n- [The EntitySchema](./schema.md) - fields + validation constraints\r\n- [Master-detail](./master-detail.md) · [Data binding](./data-binding.md)\r\n"
2808
2820
  },
2809
2821
  {
2810
2822
  "slug": "enterprise/studio/getting-started",
@@ -2966,7 +2978,7 @@ export const docs = [
2966
2978
  "slug": "getting-started-full",
2967
2979
  "path": "docs/getting-started-full.md",
2968
2980
  "title": "Getting Started with SvGrid",
2969
- "markdown": "# Getting Started with SvGrid\r\n\r\nSvGrid is a modern, production-ready data grid for Svelte 5 - a headless\r\ncore engine paired with a Svelte render component\r\n(`<SvGrid>`). It scales from a 10-row read-only table to a virtualized\r\n100,000-row, 100-column editing surface with grouping, multi-column\r\nfiltering, server-side data, and full keyboard and screen-reader\r\nsupport.\r\n\r\nThis page walks you from `pnpm add` to a feature-complete grid. It is\r\nthe canonical entry point - every other page in the documentation\r\nassumes you've finished this one. Estimated reading time: 15 minutes.\r\n\r\n> **New here?** Two short companion reads:\r\n>\r\n> - [Why headless?](./why-headless.md) - the architecture decision\r\n> behind the `createSvGrid` core vs. the `<SvGrid>` renderer.\r\n> - [Tailwind integration](./help/tailwind.md) - how `--sg-*` custom\r\n> properties + Tailwind v4 + dark mode fit together.\r\n\r\n> `@svgrid/grid` is published under the **MIT License** - permissive\r\n> for commercial use, redistribution, and modification. The paid companion\r\n> `@svgrid/enterprise` (data export + print) ships under a separate commercial\r\n> license. See [LICENSE](../LICENSE) and\r\n> [packages/enterprise/LICENSE](../packages/enterprise/LICENSE).\r\n\r\n---\r\n\r\n## Contents\r\n\r\n1. [Your first grid in 60 seconds](#1-your-first-grid-in-60-seconds)\r\n2. [Install the package](#2-install-the-package)\r\n3. [Provide row data](#3-provide-row-data)\r\n4. [Define column definitions](#4-define-column-definitions)\r\n5. [Register features (row models)](#5-register-features-row-models)\r\n6. [Styling: theme, density, dark mode](#6-styling-theme-density-dark-mode)\r\n7. [Sizing the grid](#7-sizing-the-grid)\r\n8. [Custom cells with FlexRender](#8-custom-cells-with-flexrender)\r\n9. [Sorting, filtering, pagination](#9-sorting-filtering-pagination)\r\n10. [Selection, editing, keyboard](#10-selection-editing-keyboard)\r\n11. [Server-side data](#11-server-side-data)\r\n12. [Virtualization for large datasets](#12-virtualization-for-large-datasets)\r\n13. [Accessibility](#13-accessibility)\r\n14. [TypeScript notes](#14-typescript-notes)\r\n15. [What's next](#15-whats-next)\r\n\r\n---\r\n\r\n## 1. Your first grid in 60 seconds\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid, type ColumnDef } from '@svgrid/grid'\r\n\r\n type Person = { firstName: string; age: number; status: string }\r\n\r\n const rows: Person[] = [\r\n { firstName: 'Ada', age: 36, status: 'active' },\r\n { firstName: 'Linus', age: 54, status: 'active' },\r\n { firstName: 'Grace', age: 85, status: 'inactive' },\r\n ]\r\n\r\n const columns: ColumnDef<{}, Person>[] = [\r\n { field: 'firstName', header: 'First name' },\r\n { field: 'age', header: 'Age' },\r\n { field: 'status', header: 'Status' },\r\n ]\r\n</script>\r\n\r\n<SvGrid data={rows} columns={columns} />\r\n```\r\n\r\nThat's a complete, working grid. The rest of this page is about turning\r\nit into something you'd ship.\r\n\r\n---\r\n\r\n## 2. Install the package\r\n\r\nSvGrid is a single npm package. There is no peer dependency on a CSS\r\nframework - bring your own, or use the bundled stylesheet.\r\n\r\n```bash\r\n# pnpm (recommended)\r\npnpm add @svgrid/grid\r\n\r\n# npm\r\nnpm install @svgrid/grid\r\n\r\n# yarn\r\nyarn add @svgrid/grid\r\n```\r\n\r\n**Requirements.**\r\n\r\n- Svelte **5.x** (uses runes - `$state`, `$derived`, `$effect`).\r\n- TypeScript **5.4+** (optional but recommended).\r\n- Node **18+** for tooling.\r\n\r\nOnce installed, import the component, the features you want, and the\r\nmatching `ColumnDef` type:\r\n\r\n```ts\r\nimport {\r\n SvGrid,\r\n tableFeatures,\r\n rowSortingFeature,\r\n columnFilteringFeature,\r\n type ColumnDef,\r\n} from '@svgrid/grid'\r\n```\r\n\r\nThe bundle is tree-shakeable - features you don't import don't ship. The\r\ndefault render component (`<SvGrid>`) brings its own scoped CSS, so\r\nthere's no separate stylesheet to import. Re-theming happens via the\r\n`--sg-*` custom-property surface; see\r\n[Tailwind integration](./help/tailwind.md) for the full list.\r\n\r\n---\r\n\r\n## 3. Provide row data\r\n\r\nSvGrid is data-agnostic. The `data` prop is any\r\n`ReadonlyArray<TRow>` - a Svelte 5 `$state` array, a derived store, an\r\nSWR/React-query-style cache, the result of a `+page.ts` load function,\r\nor a plain literal.\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid } from '@svgrid/grid'\r\n\r\n type Person = { id: string; firstName: string; age: number }\r\n\r\n // Reactive: pushing into `rows` updates the grid automatically.\r\n let rows = $state<Person[]>([\r\n { id: '1', firstName: 'Ada', age: 36 },\r\n { id: '2', firstName: 'Linus', age: 54 },\r\n ])\r\n\r\n function addRow() {\r\n rows.push({ id: crypto.randomUUID(), firstName: 'New', age: 0 })\r\n }\r\n</script>\r\n\r\n<button onclick={addRow}>Add row</button>\r\n<SvGrid data={rows} columns={columns} />\r\n```\r\n\r\n**Identity.** Today the wrapper uses the row's array index as its id.\r\nThat is fine for read-only data; if you mutate `rows`, prefer keeping\r\nthe same object references for rows that didn't change so selection\r\nand edit state line up. A `getRowId` prop on the wrapper is tracked in\r\n[Missing features](./help/missing-features.md) and supported by the\r\nheadless `createSvGrid` core today.\r\n\r\n**Immutability.** SvGrid never mutates your data. When you edit a cell\r\nthe grid emits an event; you decide whether to mutate in place or copy.\r\nSee [§10 - Editing](#10-selection-editing-keyboard).\r\n\r\n---\r\n\r\n## 4. Define column definitions\r\n\r\nA column definition tells SvGrid how to read a value out of a row, how\r\nto render it, and which features apply to it.\r\n\r\n```ts\r\nimport type { ColumnDef } from '@svgrid/grid'\r\n\r\ntype Person = {\r\n id: string\r\n firstName: string\r\n lastName: string\r\n age: number\r\n joinedAt: string // ISO date\r\n salary: number\r\n active: boolean\r\n}\r\n\r\nconst columns: ColumnDef<{}, Person>[] = [\r\n // Simple accessor by key\r\n { field: 'firstName', header: 'First name' },\r\n\r\n // Computed accessor\r\n {\r\n id: 'fullName',\r\n header: 'Full name',\r\n fieldFn: (row) => `${row.firstName} ${row.lastName}`,\r\n },\r\n\r\n // Numeric with locale-aware formatting\r\n {\r\n field: 'age',\r\n header: 'Age',\r\n format: { type: 'number', options: { maximumFractionDigits: 0 } },\r\n },\r\n\r\n // Date with explicit pattern\r\n {\r\n field: 'joinedAt',\r\n header: 'Joined',\r\n format: { type: 'date', pattern: 'y-m-d' },\r\n },\r\n\r\n // Currency\r\n {\r\n field: 'salary',\r\n header: 'Salary',\r\n format: { type: 'currency', currency: 'USD' },\r\n },\r\n\r\n // Boolean rendered as a checkbox\r\n {\r\n field: 'active',\r\n header: 'Active',\r\n editorType: 'checkbox',\r\n },\r\n]\r\n```\r\n\r\n**Common properties.**\r\n\r\n| Property | Purpose |\r\n| --- | --- |\r\n| `field` | Reads `row[key]`. |\r\n| `fieldFn` | Computes the value from the row. |\r\n| `id` | Stable column id (required if you use `fieldFn`). |\r\n| `header` | String or render snippet for the header. |\r\n| `footer` | String or render snippet for the footer row. |\r\n| `cell` | Render snippet/component for the body cell. |\r\n| `format` | Locale-aware formatter (`number`, `currency`, `percent`, `date`). |\r\n| `formatter` | Function for one-off custom value formatting. |\r\n| `editorType` | Inline editor: `text` \\| `number` \\| `checkbox` \\| `date` \\| `datetime`. |\r\n| `width` | Initial column width in pixels (default `columnWidth` prop). |\r\n| `align` | Header + body alignment: `'left'` \\| `'right'` \\| `'center'`. Inferred from `editorType` when omitted. |\r\n| `columns` | Child column defs (for column groups). |\r\n\r\nSorting / filtering / grouping are toggled per-grid via the registered\r\nfeatures - there is no per-column `enableSorting` / `enableColumnFilter`\r\nflag yet; those entries are in [Missing features](./help/missing-features.md).\r\n\r\nSee [`packages/grid/src/core.ts`](../packages/grid/src/core.ts)\r\nfor the full type.\r\n\r\n---\r\n\r\n## 5. Register features (row models)\r\n\r\nThe grid engine is feature-gated. Out of the box you get the **core row\r\nmodel** (the rows in their original order). To enable sorting,\r\nfiltering, grouping, expansion, pagination, or selection you opt in\r\nwith `tableFeatures(...)` and the matching `create*RowModel` factory.\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import {\r\n SvGrid,\r\n tableFeatures,\r\n rowSortingFeature,\r\n columnFilteringFeature,\r\n rowSelectionFeature,\r\n type ColumnDef,\r\n } from '@svgrid/grid'\r\n\r\n const features = tableFeatures({\r\n rowSortingFeature,\r\n columnFilteringFeature,\r\n rowSelectionFeature,\r\n })\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n showPagination={true}\r\n pageSize={25}\r\n/>\r\n```\r\n\r\n**Rule of thumb.** Only register the features you use. The wrapper wires\r\nthe matching row-model pipeline (core → filtered → sorted → grouped →\r\nexpanded) for you and exposes the user-facing toggles via props\r\n(`showPagination`, `filterMode`, `showRowSelection`, …). If you need the\r\nheadless pipeline directly - e.g. a custom renderer - drop down to\r\n`createSvGrid` from the same package; see [Why headless?](./why-headless.md).\r\n\r\n| Feature | Factory | What it does |\r\n| --- | --- | --- |\r\n| `rowSortingFeature` | `createSortedRowModel` | Click headers to sort; shift-click for multi-sort. |\r\n| `columnFilteringFeature` | `createFilteredRowModel` | Per-column filters with built-in `filterFns`. |\r\n| `rowPaginationFeature` | `createPaginatedRowModel` | Page slicing + footer state. |\r\n| `rowSelectionFeature` | - | Row checkboxes, range selection, headless API. |\r\n| `columnGroupingFeature` | `createGroupedRowModel` | Group-by-column + aggregators. |\r\n| `rowExpandingFeature` | `createExpandedRowModel` | Tree / master-detail expansion. |\r\n\r\n---\r\n\r\n## 6. Styling: theme, density, dark mode\r\n\r\nSvGrid's render component (`<SvGrid>`) ships its own scoped styles. You\r\nre-theme it by declaring `--sg-*` custom properties at any level above\r\nthe grid - the included light, dark, and high-contrast palettes in the\r\n[`10-custom-cells-and-themes`](../examples/src/demos/10-custom-cells-and-themes.svelte)\r\ndemo are themselves just three sets of these tokens applied via\r\n`style=\"--sg-bg: …; --sg-fg: …; …\"`.\r\n\r\n**Customising tokens.** Override at any level - `:root`, a wrapper, or\r\ndirectly on `<SvGrid>`.\r\n\r\n```css\r\n:root {\r\n --sg-row-height: 36px;\r\n --sg-header-bg: #f6f7f9;\r\n --sg-header-fg: #1f2933;\r\n --sg-row-hover-bg: #eef2ff;\r\n --sg-selection-bg: #dbeafe;\r\n --sg-border: #e5e7eb;\r\n --sg-focus-ring: 0 0 0 2px #2563eb;\r\n --sg-font: 'Inter', system-ui, sans-serif;\r\n}\r\n\r\n@media (prefers-color-scheme: dark) {\r\n :root {\r\n --sg-header-bg: #0f172a;\r\n --sg-header-fg: #f1f5f9;\r\n --sg-row-hover-bg: #1e293b;\r\n --sg-border: #334155;\r\n }\r\n}\r\n```\r\n\r\n**Density.** The default theme reads `--sg-row-height`; flip it to\r\n`28px` for compact mode and `48px` for comfortable. Density changes are\r\napplied without remounting the virtualizer.\r\n\r\n**Reduced motion.** Sort animations and expand transitions respect\r\n`prefers-reduced-motion: reduce` automatically.\r\n\r\n---\r\n\r\n## 7. Sizing the grid\r\n\r\n`<SvGrid>` fills its parent. Give it a height and it scrolls - without\r\none, it expands to its content and never virtualises.\r\n\r\n```svelte\r\n<!-- Fixed: 600px tall, full width. The typical choice. -->\r\n<div style=\"height: 600px;\">\r\n <SvGrid data={rows} columns={columns} />\r\n</div>\r\n\r\n<!-- Flexible: fills the viewport minus header/footer. -->\r\n<div class=\"grid-shell\">\r\n <SvGrid data={rows} columns={columns} />\r\n</div>\r\n\r\n<style>\r\n .grid-shell {\r\n height: calc(100dvh - 4rem);\r\n }\r\n</style>\r\n```\r\n\r\n**Auto-height (small datasets only).** For grids with fewer than ~200\r\nrows you can let the grid grow to its content:\r\n\r\n```svelte\r\n<SvGrid data={rows} columns={columns} domLayout=\"autoHeight\" />\r\n```\r\n\r\nAuto-height disables row virtualization. Don't use it for large data.\r\n\r\n---\r\n\r\n## 8. Custom cells with FlexRender\r\n\r\nFor anything beyond a stringified value, render with `FlexRender`,\r\n`renderComponent`, or `renderSnippet`.\r\n\r\n### As a Svelte snippet\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { renderSnippet, type ColumnDef } from '@svgrid/grid'\r\n</script>\r\n\r\n{#snippet StatusCell({ value }: { value: string })}\r\n <span class=\"pill pill-{value}\">{value}</span>\r\n{/snippet}\r\n\r\n<script lang=\"ts\">\r\n const columns: ColumnDef<{}, Person>[] = [\r\n {\r\n field: 'status',\r\n header: 'Status',\r\n cell: renderSnippet(StatusCell, (ctx) => ({ value: ctx.getValue() as string })),\r\n },\r\n ]\r\n</script>\r\n```\r\n\r\n### As a Svelte component\r\n\r\n```ts\r\nimport StatusBadge from './StatusBadge.svelte'\r\nimport { renderComponent } from '@svgrid/grid'\r\n\r\nconst columns = [\r\n {\r\n field: 'status',\r\n header: 'Status',\r\n cell: renderComponent(StatusBadge, (ctx) => ({ status: ctx.getValue() })),\r\n },\r\n]\r\n```\r\n\r\n`renderComponent` and `renderSnippet` both receive a\r\n`CellContext` so you can read sibling values, mutate state, or call\r\nback into the grid via `ctx.table`.\r\n\r\n---\r\n\r\n## 9. Sorting, filtering, pagination\r\n\r\nOnce their features are registered (see §5) the UI affordances appear\r\nautomatically. The state is controllable.\r\n\r\n**Quick way - capability shortcuts.** Every capability is off by default;\r\nthe fastest way to opt in is a boolean shortcut prop, no feature constants\r\nrequired. `sortable` and `filterable` inject the matching feature for you;\r\n`editable`, `groupable`, and `pageable` alias `enableInlineEditing`,\r\n`showGroupingControls`, and `showPagination`:\r\n\r\n```svelte\r\n<SvGrid data={rows} columns={columns} sortable filterable editable groupable pageable />\r\n```\r\n\r\nReach for the explicit `features` set + fine-grained props below when you\r\nneed more control (filter mode, page size, per-column opt-outs).\r\n\r\n### Uncontrolled (the default)\r\n\r\nThe wrapper owns sort, filter, pagination, selection, and expansion\r\nstate by default. Set the initial page size and which filter UI to\r\nshow via props:\r\n\r\n```svelte\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n showPagination={true}\r\n pageSize={50}\r\n filterMode=\"menu\"\r\n/>\r\n```\r\n\r\n### Observable (callbacks fire when state changes)\r\n\r\nThe wrapper still owns the state, but emits callbacks on every change.\r\nUse this when an outside piece of UI needs to react (a \"X rows\r\nselected\" pill, a router that syncs sort to the URL, a server fetch).\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n let sorting = $state<Array<{ id: string; desc: boolean }>>([])\r\n let filters = $state<Array<{ id: string; operator: string; value: string }>>([])\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n filterMode=\"menu\"\r\n onSortingChange={(next) => (sorting = next)}\r\n onFiltersChange={(next) => (filters = next.columns)}\r\n/>\r\n```\r\n\r\n### External (you own row ordering / filtering)\r\n\r\nFor server-side data or tree-structured data the wrapper records the\r\nsort + filter UI state but does **not** re-order the rows - you do.\r\nPair `externalSort` / `externalFilter` with the callbacks above:\r\n\r\n```svelte\r\n<SvGrid\r\n data={preFilteredRows}\r\n columns={columns}\r\n features={features}\r\n filterMode=\"menu\"\r\n externalSort={true}\r\n externalFilter={true}\r\n onSortingChange={(next) => fetchPage({ sort: next, page: 0 })}\r\n onFiltersChange={(next) => fetchPage({ filters: next.columns, page: 0 })}\r\n/>\r\n```\r\n\r\nFor Excel-style filter operators and the active-filter chip UI, see\r\n[`applyExcelFilter`](../packages/grid/src/filtering/excel-filters.ts).\r\n\r\n---\r\n\r\n## 10. Selection, editing, keyboard\r\n\r\n### Row selection\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n let selected = $state<Person[]>([])\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n selectionMode=\"row\"\r\n showRowSelection={true}\r\n onRowSelectionChange={(_state, rows) => (selected = rows)}\r\n/>\r\n\r\n{#if selected.length}\r\n <p>{selected.length} selected</p>\r\n{/if}\r\n```\r\n\r\n`selectionMode` is the umbrella prop: `'row'` shows the checkbox\r\ncolumn, `'cell'` enables click-and-drag range selection,\r\n`'both'` (default) enables both, `'none'` disables both. The\r\n`onRowSelectionChange` callback receives the selection record AND the\r\nmaterialised row array.\r\n\r\n### Cell editing\r\n\r\nSet `editorType` on each editable column. The grid handles entry,\r\ncommit, and cancel; you handle persistence.\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n function handleCellEdit(event: {\r\n rowId: string\r\n columnId: string\r\n value: unknown\r\n }) {\r\n const row = rows.find((r) => r.id === event.rowId)\r\n if (!row) return\r\n ;(row as Record<string, unknown>)[event.columnId] = event.value\r\n }\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n getRowId={(row) => row.id}\r\n onCellValueChange={handleCellEdit}\r\n/>\r\n```\r\n\r\n### Keyboard\r\n\r\nThe grid follows the WAI-ARIA grid pattern:\r\n\r\n| Keys | Action |\r\n| --- | --- |\r\n| `←` `↑` `→` `↓` | Move active cell |\r\n| `Home` / `End` | First / last column of the row |\r\n| `Ctrl+Home` / `Ctrl+End` | First / last cell of the grid |\r\n| `PageUp` / `PageDown` | Move one viewport |\r\n| `Shift + <move>` | Extend cell-range selection |\r\n| `Space` | Toggle row selection (when selection enabled) |\r\n| `Enter` / `F2` | Begin editing the active cell |\r\n| `Esc` | Cancel edit / clear selection |\r\n| `Ctrl/Cmd + C` | Copy selection as TSV |\r\n| `Ctrl/Cmd + V` | Paste TSV into selection |\r\n\r\nIf you implement your own header or toolbar, route keys through\r\n`getKeyboardIntent` and `getNextActiveCell` so behaviour stays\r\nconsistent.\r\n\r\n---\r\n\r\n## 11. Server-side data\r\n\r\nFor datasets that don't fit in memory, drive the grid from the server.\r\nThe pattern is: turn the wrapper's `onSortingChange` /\r\n`onFiltersChange` callbacks into a query, fetch, hand the page back as\r\n`data`, and use the `externalSort` + `externalFilter` props so the grid\r\ndoesn't try to re-order rows it didn't fetch.\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid, tableFeatures, rowSortingFeature,\r\n columnFilteringFeature } from '@svgrid/grid'\r\n\r\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\r\n\r\n let sort = $state<Array<{ id: string; desc: boolean }>>([])\r\n let filters = $state<Array<{ id: string; operator: string; value: string }>>([])\r\n let page = $state(0)\r\n const pageSize = 50\r\n\r\n let rows = $state<Person[]>([])\r\n let total = $state(0)\r\n let loading = $state(false)\r\n let controller: AbortController | null = null\r\n\r\n async function load() {\r\n controller?.abort()\r\n controller = new AbortController()\r\n loading = true\r\n try {\r\n const res = await fetch('/api/people?' + new URLSearchParams({\r\n sort: JSON.stringify(sort),\r\n filters: JSON.stringify(filters),\r\n page: String(page),\r\n size: String(pageSize),\r\n }), { signal: controller.signal })\r\n const body = await res.json()\r\n rows = body.rows\r\n total = body.total\r\n } catch (err) {\r\n if ((err as Error).name !== 'AbortError') throw err\r\n } finally {\r\n loading = false\r\n }\r\n }\r\n\r\n $effect(() => { sort; filters; page; load() })\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n filterMode=\"menu\"\r\n externalSort={true}\r\n externalFilter={true}\r\n showPagination={false}\r\n onSortingChange={(next) => { sort = next; page = 0 }}\r\n onFiltersChange={(next) => { filters = next.columns; page = 0 }}\r\n/>\r\n\r\n<nav>\r\n <button onclick={() => (page = Math.max(0, page - 1))}\r\n disabled={page === 0 || loading}>‹ Prev</button>\r\n <span>Page {page + 1} of {Math.ceil(total / pageSize)}</span>\r\n <button onclick={() => (page = page + 1)}\r\n disabled={(page + 1) * pageSize >= total || loading}>Next ›</button>\r\n</nav>\r\n\r\n{#if loading}<div class=\"overlay\">Loading…</div>{/if}\r\n```\r\n\r\nThe `external*` props tell the grid not to re-derive that dimension\r\nlocally - the data you pass in is already the answer. Pagination above\r\nis hand-rolled so total-row-count and \"show next page\" stay in your\r\ncontrol; if a built-in pager is enough, leave `showPagination={true}`\r\non and the wrapper will page the local `rows` array (which, in this\r\nserver-side pattern, only ever holds one page anyway).\r\n\r\nSee the [`09-server-side` demo](../examples/src/demos/09-server-side.svelte)\r\nfor a complete runnable version with debounce, abort wiring, and a\r\n60 ms mock latency.\r\n\r\n---\r\n\r\n## 12. Virtualization for large datasets\r\n\r\nFor more than a few thousand rows, enable row virtualization. For very\r\nwide grids (50+ columns) also enable column virtualization. Both are\r\nopt-in so small grids don't pay the cost.\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid } from '@svgrid/grid'\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n virtualizeRows\r\n virtualizeColumns\r\n estimatedRowHeight={36}\r\n overscan={6}\r\n/>\r\n```\r\n\r\nFor full control (e.g. variable row heights, programmatic scroll),\r\nuse the headless virtualizer directly:\r\n\r\n```ts\r\nimport { createSvelteVirtualizer } from '@svgrid/grid'\r\n\r\nconst virtualizer = createSvelteVirtualizer({\r\n count: () => rows.length,\r\n getScrollElement: () => scrollRef,\r\n estimateSize: (index) => (rows[index].kind === 'header' ? 40 : 28),\r\n overscan: 6,\r\n})\r\n\r\n// Programmatic scroll:\r\nvirtualizer.scrollToIndex(75_432, { align: 'center' })\r\n```\r\n\r\nSee [`packages/grid/src/virtualization/`](../packages/grid/src/virtualization/)\r\nfor the full API.\r\n\r\n---\r\n\r\n## 13. Accessibility\r\n\r\nSvGrid implements the WAI-ARIA 1.2 grid pattern.\r\n\r\n- The root carries `role=\"grid\"`, an accessible name (set via\r\n `aria-label` or `aria-labelledby`), and `aria-rowcount` /\r\n `aria-colcount` reflecting the total - not just the visible window.\r\n- Rows carry `role=\"row\"` plus `aria-rowindex` accounting for the\r\n virtualized offset; cells carry `role=\"gridcell\"` and `aria-colindex`.\r\n- The active cell is always exactly one focusable element\r\n (roving `tabindex`); arrow keys move it.\r\n- Sort columns carry `aria-sort=\"ascending\" | \"descending\" | \"none\"`.\r\n- Sort and selection state changes are announced via an off-screen\r\n `aria-live` region the grid manages internally.\r\n\r\nIf you build your own header or toolbar, use the helpers in\r\n[`a11y.ts`](../packages/grid/src/a11y.ts) so your markup\r\nstays consistent with the contract:\r\n\r\n```ts\r\nimport {\r\n getGridRootA11yProps,\r\n getGridRowA11yProps,\r\n getGridCellA11yProps,\r\n getGridHeaderA11yProps,\r\n} from '@svgrid/grid'\r\n```\r\n\r\nThere is a contract test suite at\r\n[`a11y.contract.test.ts`](../packages/grid/src/a11y.contract.test.ts)\r\nthat exercises the public a11y guarantees - run it (`pnpm test`) when\r\nyou customize markup to be sure you haven't regressed the contract.\r\n\r\n---\r\n\r\n## 14. TypeScript notes\r\n\r\nMost APIs are generic over your row type. Define the row type once and\r\nflow it through:\r\n\r\n```ts\r\ntype Person = { id: string; firstName: string; age: number }\r\n\r\nconst columns: ColumnDef<{}, Person>[] = [\r\n { field: 'firstName', header: 'First name' }, // ✅ key checked\r\n // { field: 'first_name', header: '…' }, // ✗ TS error\r\n]\r\n```\r\n\r\nThe first type parameter is the **feature set**. When you register\r\nfeatures, derive it once and reuse:\r\n\r\n```ts\r\nconst features = tableFeatures({\r\n rowSortingFeature,\r\n rowSelectionFeature,\r\n columnFilteringFeature,\r\n})\r\n\r\ntype Features = typeof features\r\n\r\nconst columns: ColumnDef<Features, Person>[] = [/* … */]\r\n```\r\n\r\nThis lets feature-specific column properties (like `filterFn`)\r\nauto-complete and type-check.\r\n\r\n---\r\n\r\n## 15. What's next\r\n\r\n### Core features\r\n\r\n- **[Examples gallery](https://svgrid.com/demos/)** - 50+ production-quality\r\n demos, from quick-start to 100k-row virtualization.\r\n- **[Column definitions](./help/columns/column-definitions.md)** - every\r\n property on `ColumnDef`.\r\n- **[Row sorting](./help/rows/row-sorting.md)** and the wider [rows topic index](./help/index.md#rows) -\r\n when to use which row model and the order they run in.\r\n- **[Tree rows (expand / collapse)](./help/rows/tree-rows.md)** - the\r\n flat-array + expanded-map pattern, connector lines, keyboard\r\n navigation, and lazy load on first expand.\r\n- **[Filter API](./help/filtering/filter-api.md)** - sort, filter,\r\n paginate, group, and aggregate locally or against your backend.\r\n- **[Tailwind integration](./help/tailwind.md)** - full list of CSS\r\n custom properties (`--sg-*`) and recipes for building your own theme.\r\n- **[Compare SvGrid with other Svelte data grids](https://svgrid.com/compare/)** -\r\n side-by-side feature matrix and when to pick which.\r\n\r\n### Enterprise features (`@svgrid/enterprise`)\r\n\r\nThe paid companion package augments your `SvGridApi` with one\r\n`installEnterprise(api)` call. Set a license key at app boot to remove the\r\n\"unlicensed\" watermark - every feature still runs without a key for\r\ndemos and evaluation.\r\n\r\n- **[Data export and printing](./help/export.md)** - Excel (xlsx), PDF,\r\n CSV, TSV, HTML, and a paginated print view. Defaults to the currently\r\n displayed rows so sort + filter + paginate carry through automatically.\r\n- **[Data import](./help/import.md)** - Excel (xlsx), CSV, TSV, and JSON\r\n with column mapping, per-row validation, and preview-before-commit. Auto-\r\n detects the format from the file extension or pasted text.\r\n- **[Pivot tables](./help/pivot.md)** - drag-and-drop Pivot Designer with\r\n Filters / Rows / Columns / Values zones, multi-level column headers,\r\n per-measure aggregator picker, pivot-aware sort. Built on the same\r\n engine; no special \"pivot mode\".\r\n- **[AI assistant](./help/ai.md)** - natural-language filter, smart fill,\r\n summarise, and classify, driven by a bring-your-own model adapter\r\n (`setAIProvider(fn)`). Ships with a deterministic `mockAIProvider` so\r\n the demo works without keys.\r\n\r\n### Getting help\r\n\r\n- File issues at the [project repository](https://github.com/sv-grid/sv-grid/issues).\r\n- Browse the [Help index](./help/index.md) for topic-oriented guides.\r\n- Use the [@svgrid/mcp](https://svgrid.com/mcp/) server\r\n to give your AI assistant accurate answers.\r\n- Read the source - it is small, well-commented, and meant to be read\r\n before opening a bug report.\r\n\r\n### License\r\n\r\n`@svgrid/grid` is published under the **MIT License**. Free for\r\ncommercial and personal use. The paid `@svgrid/enterprise` companion package\r\n(export, import, print, pivot, AI assistant) is governed by a separate\r\ncommercial license. See [LICENSE](../LICENSE) and\r\n[packages/enterprise/LICENSE](../packages/enterprise/LICENSE).\r\n"
2981
+ "markdown": "# Getting Started with SvGrid\r\n\r\nSvGrid is a modern, production-ready data grid for Svelte 5 - a headless\r\ncore engine paired with a Svelte render component\r\n(`<SvGrid>`). It scales from a 10-row read-only table to a virtualized\r\n100,000-row, 100-column editing surface with grouping, multi-column\r\nfiltering, server-side data, and full keyboard and screen-reader\r\nsupport.\r\n\r\nThis page walks you from `pnpm add` to a feature-complete grid. It is\r\nthe canonical entry point - every other page in the documentation\r\nassumes you've finished this one. Estimated reading time: 15 minutes.\r\n\r\n> **New here?** Two short companion reads:\r\n>\r\n> - [Why headless?](./why-headless.md) - the architecture decision\r\n> behind the `createSvGrid` core vs. the `<SvGrid>` renderer.\r\n> - [Tailwind integration](./help/tailwind.md) - how `--sg-*` custom\r\n> properties + Tailwind v4 + dark mode fit together.\r\n\r\n> `@svgrid/grid` is published under the **MIT License** - permissive\r\n> for commercial use, redistribution, and modification. The paid companion\r\n> `@svgrid/enterprise` (data export + print) ships under a separate commercial\r\n> license. See [LICENSE](../LICENSE) and\r\n> [packages/enterprise/LICENSE](../packages/enterprise/LICENSE).\r\n\r\n---\r\n\r\n## Contents\r\n\r\n1. [Your first grid in 60 seconds](#1-your-first-grid-in-60-seconds)\r\n2. [Install the package](#2-install-the-package)\r\n3. [Provide row data](#3-provide-row-data)\r\n4. [Define column definitions](#4-define-column-definitions)\r\n5. [Register features (row models)](#5-register-features-row-models)\r\n6. [Styling: theme, density, dark mode](#6-styling-theme-density-dark-mode)\r\n7. [Sizing the grid](#7-sizing-the-grid)\r\n8. [Custom cells with FlexRender](#8-custom-cells-with-flexrender)\r\n9. [Sorting, filtering, pagination](#9-sorting-filtering-pagination)\r\n10. [Selection, editing, keyboard](#10-selection-editing-keyboard)\r\n11. [Server-side data](#11-server-side-data)\r\n12. [Virtualization for large datasets](#12-virtualization-for-large-datasets)\r\n13. [Accessibility](#13-accessibility)\r\n14. [TypeScript notes](#14-typescript-notes)\r\n15. [What's next](#15-whats-next)\r\n\r\n---\r\n\r\n## 1. Your first grid in 60 seconds\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid, type ColumnDef } from '@svgrid/grid'\r\n\r\n type Person = { firstName: string; age: number; status: string }\r\n\r\n const rows: Person[] = [\r\n { firstName: 'Ada', age: 36, status: 'active' },\r\n { firstName: 'Linus', age: 54, status: 'active' },\r\n { firstName: 'Grace', age: 85, status: 'inactive' },\r\n ]\r\n\r\n const columns: ColumnDef<{}, Person>[] = [\r\n { field: 'firstName', header: 'First name' },\r\n { field: 'age', header: 'Age' },\r\n { field: 'status', header: 'Status' },\r\n ]\r\n</script>\r\n\r\n<SvGrid data={rows} columns={columns} />\r\n```\r\n\r\nThat's a complete, working grid. The rest of this page is about turning\r\nit into something you'd ship.\r\n\r\n---\r\n\r\n## 2. Install the package\r\n\r\nSvGrid is a single npm package. There is no peer dependency on a CSS\r\nframework - bring your own, or use the bundled stylesheet.\r\n\r\n```bash\r\n# pnpm (recommended)\r\npnpm add @svgrid/grid\r\n\r\n# npm\r\nnpm install @svgrid/grid\r\n\r\n# yarn\r\nyarn add @svgrid/grid\r\n```\r\n\r\n**Requirements.**\r\n\r\n- Svelte **5.x** (uses runes - `$state`, `$derived`, `$effect`).\r\n- TypeScript **5.4+** (optional but recommended).\r\n- Node **18+** for tooling.\r\n\r\nOnce installed, import the component, the features you want, and the\r\nmatching `ColumnDef` type:\r\n\r\n```ts\r\nimport {\r\n SvGrid,\r\n tableFeatures,\r\n rowSortingFeature,\r\n columnFilteringFeature,\r\n type ColumnDef,\r\n} from '@svgrid/grid'\r\n```\r\n\r\nThe bundle is tree-shakeable - features you don't import don't ship. The\r\ndefault render component (`<SvGrid>`) brings its own scoped CSS, so\r\nthere's no separate stylesheet to import. Re-theming happens via the\r\n`--sg-*` custom-property surface; see\r\n[Tailwind integration](./help/tailwind.md) for the full list.\r\n\r\n---\r\n\r\n## 3. Provide row data\r\n\r\nSvGrid is data-agnostic. The `data` prop is any\r\n`ReadonlyArray<TRow>` - a Svelte 5 `$state` array, a derived store, an\r\nSWR/React-query-style cache, the result of a `+page.ts` load function,\r\nor a plain literal.\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid } from '@svgrid/grid'\r\n\r\n type Person = { id: string; firstName: string; age: number }\r\n\r\n // Reactive: pushing into `rows` updates the grid automatically.\r\n let rows = $state<Person[]>([\r\n { id: '1', firstName: 'Ada', age: 36 },\r\n { id: '2', firstName: 'Linus', age: 54 },\r\n ])\r\n\r\n function addRow() {\r\n rows.push({ id: crypto.randomUUID(), firstName: 'New', age: 0 })\r\n }\r\n</script>\r\n\r\n<button onclick={addRow}>Add row</button>\r\n<SvGrid data={rows} columns={columns} />\r\n```\r\n\r\n**Identity.** Today the wrapper uses the row's array index as its id.\r\nThat is fine for read-only data; if you mutate `rows`, prefer keeping\r\nthe same object references for rows that didn't change so selection\r\nand edit state line up. A `getRowId` prop on the wrapper is tracked in\r\n[Missing features](./help/missing-features.md) and supported by the\r\nheadless `createSvGrid` core today.\r\n\r\n**Immutability.** SvGrid never mutates your data. When you edit a cell\r\nthe grid emits an event; you decide whether to mutate in place or copy.\r\nSee [§10 - Editing](#10-selection-editing-keyboard).\r\n\r\n---\r\n\r\n## 4. Define column definitions\r\n\r\nA column definition tells SvGrid how to read a value out of a row, how\r\nto render it, and which features apply to it.\r\n\r\n```ts\r\nimport type { ColumnDef } from '@svgrid/grid'\r\n\r\ntype Person = {\r\n id: string\r\n firstName: string\r\n lastName: string\r\n age: number\r\n joinedAt: string // ISO date\r\n salary: number\r\n active: boolean\r\n}\r\n\r\nconst columns: ColumnDef<{}, Person>[] = [\r\n // Simple accessor by key\r\n { field: 'firstName', header: 'First name' },\r\n\r\n // Computed accessor\r\n {\r\n id: 'fullName',\r\n header: 'Full name',\r\n fieldFn: (row) => `${row.firstName} ${row.lastName}`,\r\n },\r\n\r\n // Numeric with locale-aware formatting\r\n {\r\n field: 'age',\r\n header: 'Age',\r\n format: { type: 'number', options: { maximumFractionDigits: 0 } },\r\n },\r\n\r\n // Date with explicit pattern\r\n {\r\n field: 'joinedAt',\r\n header: 'Joined',\r\n format: { type: 'date', pattern: 'y-m-d' },\r\n },\r\n\r\n // Currency\r\n {\r\n field: 'salary',\r\n header: 'Salary',\r\n format: { type: 'currency', currency: 'USD' },\r\n },\r\n\r\n // Boolean rendered as a checkbox\r\n {\r\n field: 'active',\r\n header: 'Active',\r\n editorType: 'checkbox',\r\n },\r\n]\r\n```\r\n\r\n**Common properties.**\r\n\r\n| Property | Purpose |\r\n| --- | --- |\r\n| `field` | Reads `row[key]`. |\r\n| `fieldFn` | Computes the value from the row. |\r\n| `id` | Stable column id (required if you use `fieldFn`). |\r\n| `header` | String or render snippet for the header. |\r\n| `footer` | String or render snippet for the footer row. |\r\n| `cell` | Render snippet/component for the body cell. |\r\n| `format` | Locale-aware formatter (`number`, `currency`, `percent`, `date`). |\r\n| `formatter` | Function for one-off custom value formatting. |\r\n| `editorType` | Inline editor: `text` \\| `number` \\| `checkbox` \\| `date` \\| `datetime`. |\r\n| `width` | Initial column width in pixels (default `columnWidth` prop). |\r\n| `align` | Header + body alignment: `'left'` \\| `'right'` \\| `'center'`. Inferred from `editorType` when omitted. |\r\n| `columns` | Child column defs (for column groups). |\r\n\r\nSorting / filtering / grouping are toggled per-grid via the registered\r\nfeatures - there is no per-column `enableSorting` / `enableColumnFilter`\r\nflag yet; those entries are in [Missing features](./help/missing-features.md).\r\n\r\nSee [`packages/grid/src/core.ts`](../packages/grid/src/core.ts)\r\nfor the full type.\r\n\r\n---\r\n\r\n## 5. Register features (row models)\r\n\r\nThe grid engine is feature-gated. Out of the box you get the **core row\r\nmodel** (the rows in their original order). To enable sorting,\r\nfiltering, grouping, expansion, pagination, or selection you opt in\r\nwith `tableFeatures(...)` and the matching `create*RowModel` factory.\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import {\r\n SvGrid,\r\n tableFeatures,\r\n rowSortingFeature,\r\n columnFilteringFeature,\r\n rowSelectionFeature,\r\n type ColumnDef,\r\n } from '@svgrid/grid'\r\n\r\n const features = tableFeatures({\r\n rowSortingFeature,\r\n columnFilteringFeature,\r\n rowSelectionFeature,\r\n })\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n showPagination={true}\r\n pageSize={25}\r\n/>\r\n```\r\n\r\n**Rule of thumb.** Only register the features you use. The wrapper wires\r\nthe matching row-model pipeline (core → filtered → sorted → grouped →\r\nexpanded) for you and exposes the user-facing toggles via props\r\n(`showPagination`, `filterMode`, `showRowSelection`, …). If you need the\r\nheadless pipeline directly - e.g. a custom renderer - drop down to\r\n`createSvGrid` from the same package; see [Why headless?](./why-headless.md).\r\n\r\n| Feature | Factory | What it does |\r\n| --- | --- | --- |\r\n| `rowSortingFeature` | `createSortedRowModel` | Click headers to sort; shift-click for multi-sort. |\r\n| `columnFilteringFeature` | `createFilteredRowModel` | Per-column filters with built-in `filterFns`. |\r\n| `rowPaginationFeature` | `createPaginatedRowModel` | Page slicing + footer state. |\r\n| `rowSelectionFeature` | - | Row checkboxes, range selection, headless API. |\r\n| `columnGroupingFeature` | `createGroupedRowModel` | Group-by-column + aggregators. |\r\n| `rowExpandingFeature` | `createExpandedRowModel` | Tree / master-detail expansion. |\r\n\r\n---\r\n\r\n## 6. Styling: theme, density, dark mode\r\n\r\nSvGrid's render component (`<SvGrid>`) ships its own scoped styles. You\r\nre-theme it by declaring `--sg-*` custom properties at any level above\r\nthe grid - the included light, dark, and high-contrast palettes in the\r\n[`10-custom-cells-and-themes`](../examples/src/demos/10-custom-cells-and-themes.svelte)\r\ndemo are themselves just three sets of these tokens applied via\r\n`style=\"--sg-bg: …; --sg-fg: …; …\"`.\r\n\r\n**Customising tokens.** Override at any level - `:root`, a wrapper, or\r\ndirectly on `<SvGrid>`.\r\n\r\n```css\r\n:root {\r\n --sg-header-bg: #f6f7f9;\r\n --sg-header-fg: #1f2933;\r\n --sg-row-hover-bg: #eef2ff;\r\n --sg-selection-bg: #dbeafe;\r\n --sg-border: #e5e7eb;\r\n --sg-focus-ring: 0 0 0 2px #2563eb;\r\n --sg-font: 'Inter', system-ui, sans-serif;\r\n}\r\n\r\n@media (prefers-color-scheme: dark) {\r\n :root {\r\n --sg-header-bg: #0f172a;\r\n --sg-header-fg: #f1f5f9;\r\n --sg-row-hover-bg: #1e293b;\r\n --sg-border: #334155;\r\n }\r\n}\r\n```\r\n\r\n**Density.** Row height is the one piece of the look that is a prop\r\nrather than a token - the virtualizer needs it as a number, and the\r\ngrid writes it as an inline style on each row. Pass `rowHeight`\r\n(default `30`): `28` for compact, `48` for comfortable. Changing it\r\ndoes not remount the virtualizer.\r\n\r\n**Reduced motion.** Sort animations and expand transitions respect\r\n`prefers-reduced-motion: reduce` automatically.\r\n\r\n---\r\n\r\n## 7. Sizing the grid\r\n\r\nThe grid's scroll shell is sized by `containerHeight`, which defaults to\r\n520 px. A number is pixels; a string is passed through to CSS, so `'100%'`\r\nfills the parent and `'auto'` grows to the content.\r\n\r\n```svelte\r\n<!-- Fixed: 600px tall. The typical choice. -->\r\n<SvGrid data={rows} columns={columns} containerHeight={600} />\r\n\r\n<!-- Flexible: fills a parent that sets the height itself. -->\r\n<div class=\"grid-shell\">\r\n <SvGrid data={rows} columns={columns} containerHeight=\"100%\" />\r\n</div>\r\n\r\n<style>\r\n .grid-shell {\r\n height: calc(100dvh - 4rem);\r\n }\r\n</style>\r\n```\r\n\r\n**Auto-height (small datasets only).** For grids with fewer than ~200\r\nrows you can let the grid grow to its content. Turn row virtualization\r\noff as well - a shell with no fixed height gives the virtualizer nothing\r\nto measure against:\r\n\r\n```svelte\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n containerHeight=\"auto\"\r\n virtualization={false}\r\n/>\r\n```\r\n\r\nDon't use this for large data: every row renders.\r\n\r\n---\r\n\r\n## 8. Custom cells with FlexRender\r\n\r\nFor anything beyond a stringified value, render with `FlexRender`,\r\n`renderComponent`, or `renderSnippet`.\r\n\r\n### As a Svelte snippet\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { renderSnippet, type ColumnDef } from '@svgrid/grid'\r\n</script>\r\n\r\n{#snippet StatusCell({ value }: { value: string })}\r\n <span class=\"pill pill-{value}\">{value}</span>\r\n{/snippet}\r\n\r\n<script lang=\"ts\">\r\n const columns: ColumnDef<{}, Person>[] = [\r\n {\r\n field: 'status',\r\n header: 'Status',\r\n cell: renderSnippet(StatusCell, (ctx) => ({ value: ctx.getValue() as string })),\r\n },\r\n ]\r\n</script>\r\n```\r\n\r\n### As a Svelte component\r\n\r\n```ts\r\nimport StatusBadge from './StatusBadge.svelte'\r\nimport { renderComponent } from '@svgrid/grid'\r\n\r\nconst columns = [\r\n {\r\n field: 'status',\r\n header: 'Status',\r\n cell: renderComponent(StatusBadge, (ctx) => ({ status: ctx.getValue() })),\r\n },\r\n]\r\n```\r\n\r\n`renderComponent` and `renderSnippet` both receive a\r\n`CellContext` so you can read sibling values, mutate state, or call\r\nback into the grid via `ctx.table`.\r\n\r\n---\r\n\r\n## 9. Sorting, filtering, pagination\r\n\r\nOnce their features are registered (see §5) the UI affordances appear\r\nautomatically. The state is controllable.\r\n\r\n**Quick way - capability shortcuts.** Every capability is off by default;\r\nthe fastest way to opt in is a boolean shortcut prop, no feature constants\r\nrequired. `sortable` and `filterable` inject the matching feature for you;\r\n`editable`, `groupable`, and `pageable` alias `enableInlineEditing`,\r\n`showGroupingControls`, and `showPagination`:\r\n\r\n```svelte\r\n<SvGrid data={rows} columns={columns} sortable filterable editable groupable pageable />\r\n```\r\n\r\nReach for the explicit `features` set + fine-grained props below when you\r\nneed more control (filter mode, page size, per-column opt-outs).\r\n\r\n### Uncontrolled (the default)\r\n\r\nThe wrapper owns sort, filter, pagination, selection, and expansion\r\nstate by default. Set the initial page size and which filter UI to\r\nshow via props:\r\n\r\n```svelte\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n showPagination={true}\r\n pageSize={50}\r\n filterMode=\"menu\"\r\n/>\r\n```\r\n\r\n### Observable (callbacks fire when state changes)\r\n\r\nThe wrapper still owns the state, but emits callbacks on every change.\r\nUse this when an outside piece of UI needs to react (a \"X rows\r\nselected\" pill, a router that syncs sort to the URL, a server fetch).\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n let sorting = $state<Array<{ id: string; desc: boolean }>>([])\r\n let filters = $state<Array<{ id: string; operator: string; value: string }>>([])\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n filterMode=\"menu\"\r\n onSortingChange={(next) => (sorting = next)}\r\n onFiltersChange={(next) => (filters = next.columns)}\r\n/>\r\n```\r\n\r\n### External (you own row ordering / filtering)\r\n\r\nFor server-side data or tree-structured data the wrapper records the\r\nsort + filter UI state but does **not** re-order the rows - you do.\r\nPair `externalSort` / `externalFilter` with the callbacks above:\r\n\r\n```svelte\r\n<SvGrid\r\n data={preFilteredRows}\r\n columns={columns}\r\n features={features}\r\n filterMode=\"menu\"\r\n externalSort={true}\r\n externalFilter={true}\r\n onSortingChange={(next) => fetchPage({ sort: next, page: 0 })}\r\n onFiltersChange={(next) => fetchPage({ filters: next.columns, page: 0 })}\r\n/>\r\n```\r\n\r\nFor Excel-style filter operators and the active-filter chip UI, see\r\n[`applyExcelFilter`](../packages/grid/src/filtering/excel-filters.ts).\r\n\r\n---\r\n\r\n## 10. Selection, editing, keyboard\r\n\r\n### Row selection\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n let selected = $state<Person[]>([])\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n selectionMode=\"row\"\r\n showRowSelection={true}\r\n onRowSelectionChange={(_state, rows) => (selected = rows)}\r\n/>\r\n\r\n{#if selected.length}\r\n <p>{selected.length} selected</p>\r\n{/if}\r\n```\r\n\r\n`selectionMode` is the umbrella prop: `'row'` shows the checkbox\r\ncolumn, `'cell'` enables click-and-drag range selection,\r\n`'both'` (default) enables both, `'none'` disables both. The\r\n`onRowSelectionChange` callback receives the selection record AND the\r\nmaterialised row array.\r\n\r\n### Cell editing\r\n\r\nSet `editorType` on each editable column. The grid handles entry,\r\ncommit, and cancel; you handle persistence.\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n function handleCellEdit(event: {\r\n rowId: string\r\n columnId: string\r\n value: unknown\r\n }) {\r\n const row = rows.find((r) => r.id === event.rowId)\r\n if (!row) return\r\n ;(row as Record<string, unknown>)[event.columnId] = event.value\r\n }\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n getRowId={(row) => row.id}\r\n onCellValueChange={handleCellEdit}\r\n/>\r\n```\r\n\r\n### Keyboard\r\n\r\nThe grid follows the WAI-ARIA grid pattern:\r\n\r\n| Keys | Action |\r\n| --- | --- |\r\n| `←` `↑` `→` `↓` | Move active cell |\r\n| `Home` / `End` | First / last column of the row |\r\n| `Ctrl+Home` / `Ctrl+End` | First / last cell of the grid |\r\n| `PageUp` / `PageDown` | Move one viewport |\r\n| `Shift + <move>` | Extend cell-range selection |\r\n| `Space` | Toggle row selection (when selection enabled) |\r\n| `Enter` / `F2` | Begin editing the active cell |\r\n| `Esc` | Cancel edit / clear selection |\r\n| `Ctrl/Cmd + C` | Copy selection as TSV |\r\n| `Ctrl/Cmd + V` | Paste TSV into selection |\r\n\r\nIf you implement your own header or toolbar, route keys through\r\n`getKeyboardIntent` and `getNextActiveCell` so behaviour stays\r\nconsistent.\r\n\r\n---\r\n\r\n## 11. Server-side data\r\n\r\nFor datasets that don't fit in memory, drive the grid from the server.\r\nThe pattern is: turn the wrapper's `onSortingChange` /\r\n`onFiltersChange` callbacks into a query, fetch, hand the page back as\r\n`data`, and use the `externalSort` + `externalFilter` props so the grid\r\ndoesn't try to re-order rows it didn't fetch.\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid, tableFeatures, rowSortingFeature,\r\n columnFilteringFeature } from '@svgrid/grid'\r\n\r\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\r\n\r\n let sort = $state<Array<{ id: string; desc: boolean }>>([])\r\n let filters = $state<Array<{ id: string; operator: string; value: string }>>([])\r\n let page = $state(0)\r\n const pageSize = 50\r\n\r\n let rows = $state<Person[]>([])\r\n let total = $state(0)\r\n let loading = $state(false)\r\n let controller: AbortController | null = null\r\n\r\n async function load() {\r\n controller?.abort()\r\n controller = new AbortController()\r\n loading = true\r\n try {\r\n const res = await fetch('/api/people?' + new URLSearchParams({\r\n sort: JSON.stringify(sort),\r\n filters: JSON.stringify(filters),\r\n page: String(page),\r\n size: String(pageSize),\r\n }), { signal: controller.signal })\r\n const body = await res.json()\r\n rows = body.rows\r\n total = body.total\r\n } catch (err) {\r\n if ((err as Error).name !== 'AbortError') throw err\r\n } finally {\r\n loading = false\r\n }\r\n }\r\n\r\n $effect(() => { sort; filters; page; load() })\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n filterMode=\"menu\"\r\n externalSort={true}\r\n externalFilter={true}\r\n showPagination={false}\r\n onSortingChange={(next) => { sort = next; page = 0 }}\r\n onFiltersChange={(next) => { filters = next.columns; page = 0 }}\r\n/>\r\n\r\n<nav>\r\n <button onclick={() => (page = Math.max(0, page - 1))}\r\n disabled={page === 0 || loading}>‹ Prev</button>\r\n <span>Page {page + 1} of {Math.ceil(total / pageSize)}</span>\r\n <button onclick={() => (page = page + 1)}\r\n disabled={(page + 1) * pageSize >= total || loading}>Next ›</button>\r\n</nav>\r\n\r\n{#if loading}<div class=\"overlay\">Loading…</div>{/if}\r\n```\r\n\r\nThe `external*` props tell the grid not to re-derive that dimension\r\nlocally - the data you pass in is already the answer. Pagination above\r\nis hand-rolled so total-row-count and \"show next page\" stay in your\r\ncontrol; if a built-in pager is enough, leave `showPagination={true}`\r\non and the wrapper will page the local `rows` array (which, in this\r\nserver-side pattern, only ever holds one page anyway).\r\n\r\nSee the [`09-server-side` demo](../examples/src/demos/09-server-side.svelte)\r\nfor a complete runnable version with debounce, abort wiring, and a\r\n60 ms mock latency.\r\n\r\n---\r\n\r\n## 12. Virtualization for large datasets\r\n\r\nRow and column virtualization are both **on by default** - you opt out,\r\nnot in. `virtualization={false}` and `columnVirtualization={false}` render\r\nevery row / column, which is what you want for a short grid you intend to\r\nprint, or when sticky column pinning has to survive (the column virtualizer\r\nrecycles DOM nodes, so the two cannot co-exist).\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid } from '@svgrid/grid'\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n rowHeight={36}\r\n overscan={6}\r\n/>\r\n```\r\n\r\n`rowHeight` takes a number (default 30) or a `(rowIndex) => px` function for\r\nper-row heights. When row content varies and you cannot predict it, set\r\n`autoRowHeight` instead and the grid measures each row. `overscan` (default 8)\r\nis how many rows beyond the viewport stay mounted.\r\n\r\nFor full control (e.g. variable row heights, programmatic scroll),\r\nuse the headless virtualizer directly:\r\n\r\n```ts\r\nimport { createSvelteVirtualizer } from '@svgrid/grid'\r\n\r\nconst virtualizer = createSvelteVirtualizer({\r\n count: () => rows.length,\r\n getScrollElement: () => scrollRef,\r\n estimateSize: (index) => (rows[index].kind === 'header' ? 40 : 28),\r\n overscan: 6,\r\n})\r\n\r\n// Programmatic scroll:\r\nvirtualizer.scrollToIndex(75_432, { align: 'center' })\r\n```\r\n\r\nSee [`packages/grid/src/virtualization/`](../packages/grid/src/virtualization/)\r\nfor the full API.\r\n\r\n---\r\n\r\n## 13. Accessibility\r\n\r\nSvGrid implements the WAI-ARIA 1.2 grid pattern.\r\n\r\n- The root carries `role=\"grid\"`, an accessible name (set via\r\n `aria-label` or `aria-labelledby`), and `aria-rowcount` /\r\n `aria-colcount` reflecting the total - not just the visible window.\r\n- Rows carry `role=\"row\"` plus `aria-rowindex` accounting for the\r\n virtualized offset; cells carry `role=\"gridcell\"` and `aria-colindex`.\r\n- The active cell is always exactly one focusable element\r\n (roving `tabindex`); arrow keys move it.\r\n- Sort columns carry `aria-sort=\"ascending\" | \"descending\" | \"none\"`.\r\n- Sort and selection state changes are announced via an off-screen\r\n `aria-live` region the grid manages internally.\r\n\r\nIf you build your own header or toolbar, use the helpers in\r\n[`a11y.ts`](../packages/grid/src/a11y.ts) so your markup\r\nstays consistent with the contract:\r\n\r\n```ts\r\nimport {\r\n getGridRootA11yProps,\r\n getGridRowA11yProps,\r\n getGridCellA11yProps,\r\n getGridHeaderA11yProps,\r\n} from '@svgrid/grid'\r\n```\r\n\r\nThere is a contract test suite at\r\n[`a11y.contract.test.ts`](../packages/grid/src/a11y.contract.test.ts)\r\nthat exercises the public a11y guarantees - run it (`pnpm test`) when\r\nyou customize markup to be sure you haven't regressed the contract.\r\n\r\n---\r\n\r\n## 14. TypeScript notes\r\n\r\nMost APIs are generic over your row type. Define the row type once and\r\nflow it through:\r\n\r\n```ts\r\ntype Person = { id: string; firstName: string; age: number }\r\n\r\nconst columns: ColumnDef<{}, Person>[] = [\r\n { field: 'firstName', header: 'First name' }, // ✅ key checked\r\n // { field: 'first_name', header: '…' }, // ✗ TS error\r\n]\r\n```\r\n\r\nThe first type parameter is the **feature set**. When you register\r\nfeatures, derive it once and reuse:\r\n\r\n```ts\r\nconst features = tableFeatures({\r\n rowSortingFeature,\r\n rowSelectionFeature,\r\n columnFilteringFeature,\r\n})\r\n\r\ntype Features = typeof features\r\n\r\nconst columns: ColumnDef<Features, Person>[] = [/* … */]\r\n```\r\n\r\nThis lets feature-specific column properties (like `filterFn`)\r\nauto-complete and type-check.\r\n\r\n---\r\n\r\n## 15. What's next\r\n\r\n### Core features\r\n\r\n- **[Examples gallery](https://svgrid.com/demos/)** - 50+ production-quality\r\n demos, from quick-start to 100k-row virtualization.\r\n- **[Column definitions](./help/columns/column-definitions.md)** - every\r\n property on `ColumnDef`.\r\n- **[Row sorting](./help/rows/row-sorting.md)** and the wider [rows topic index](./help/index.md#rows) -\r\n when to use which row model and the order they run in.\r\n- **[Tree rows (expand / collapse)](./help/rows/tree-rows.md)** - the\r\n flat-array + expanded-map pattern, connector lines, keyboard\r\n navigation, and lazy load on first expand.\r\n- **[Filter API](./help/filtering/filter-api.md)** - sort, filter,\r\n paginate, group, and aggregate locally or against your backend.\r\n- **[Tailwind integration](./help/tailwind.md)** - full list of CSS\r\n custom properties (`--sg-*`) and recipes for building your own theme.\r\n- **[Compare SvGrid with other Svelte data grids](https://svgrid.com/compare/)** -\r\n side-by-side feature matrix and when to pick which.\r\n\r\n### Enterprise features (`@svgrid/enterprise`)\r\n\r\nThe paid companion package augments your `SvGridApi` with one\r\n`installEnterprise(api)` call. Set a license key at app boot to remove the\r\n\"unlicensed\" watermark - every feature still runs without a key for\r\ndemos and evaluation.\r\n\r\n- **[Data export and printing](./help/export.md)** - Excel (xlsx), PDF,\r\n CSV, TSV, HTML, and a paginated print view. Defaults to the currently\r\n displayed rows so sort + filter + paginate carry through automatically.\r\n- **[Data import](./help/import.md)** - Excel (xlsx), CSV, TSV, and JSON\r\n with column mapping, per-row validation, and preview-before-commit. Auto-\r\n detects the format from the file extension or pasted text.\r\n- **[Pivot tables](./help/pivot.md)** - drag-and-drop Pivot Designer with\r\n Filters / Rows / Columns / Values zones, multi-level column headers,\r\n per-measure aggregator picker, pivot-aware sort. Built on the same\r\n engine; no special \"pivot mode\".\r\n- **[AI assistant](./help/ai.md)** - natural-language filter, smart fill,\r\n summarise, and classify, driven by a bring-your-own model adapter\r\n (`setAIProvider(fn)`). Ships with a deterministic `mockAIProvider` so\r\n the demo works without keys.\r\n\r\n### Getting help\r\n\r\n- File issues at the [project repository](https://github.com/sv-grid/sv-grid/issues).\r\n- Browse the [Help index](./help/index.md) for topic-oriented guides.\r\n- Use the [@svgrid/mcp](https://svgrid.com/mcp/) server\r\n to give your AI assistant accurate answers.\r\n- Read the source - it is small, well-commented, and meant to be read\r\n before opening a bug report.\r\n\r\n### License\r\n\r\n`@svgrid/grid` is published under the **MIT License**. Free for\r\ncommercial and personal use. The paid `@svgrid/enterprise` companion package\r\n(export, import, print, pivot, AI assistant) is governed by a separate\r\ncommercial license. See [LICENSE](../LICENSE) and\r\n[packages/enterprise/LICENSE](../packages/enterprise/LICENSE).\r\n"
2970
2982
  },
2971
2983
  {
2972
2984
  "slug": "getting-started",
@@ -3002,7 +3014,7 @@ export const docs = [
3002
3014
  "slug": "getting-started/5-theme-and-density",
3003
3015
  "path": "docs/getting-started/5-theme-and-density.md",
3004
3016
  "title": "5. Theme and density",
3005
- "markdown": "# 5. Theme and density\r\n\r\n> Step 5 of 6 · [← Features](./4-features.md) · [Next: Going to production →](./6-going-to-production.md)\r\n\r\n## Start with a preset\r\n\r\nBefore hand-writing any tokens: 20 design-system presets ship with the package,\r\neach a single stylesheet with a full light + dark palette. One import re-themes\r\nthe whole grid.\r\n\r\n```ts\r\nimport '@svgrid/grid/themes/shadcn.css'\r\n```\r\n\r\nAvailable: `ember` (SvGrid's own look), `shadcn`, `tailwind`, `material`,\r\n`fluent`, `carbon`, `antd`, `bootstrap`, `atlassian`, `salesforce`, `sap`,\r\n`github`, `linear`, `notion`, `vercel`, `excel`, `nord`, `dracula`,\r\n`catppuccin`, `ag-alpine`.\r\n\r\nEach preset defines every token the grid and the UI components read - including\r\nthe semantic status colors (`--sg-danger`, `--sg-success`, `--sg-warning`,\r\n`--sg-info`) and the focus ring, which follows the preset's accent. Presets\r\nflip with `data-theme=\"dark\"` automatically (see below).\r\n\r\nOverride individual tokens after the import to adjust a preset, or skip presets\r\nentirely and declare the tokens yourself - that is the rest of this page.\r\n\r\n## Declaring tokens yourself\r\n\r\nThe render component (`<SvGrid>`) ships its own scoped styles. You\r\nre-theme it by declaring `--sg-*` CSS custom properties at any level\r\nabove the grid - `:root` for the whole app, a wrapper `<div>` for one\r\ninstance, or directly on the `<SvGrid>` element itself.\r\n\r\n![The --sg-* CSS custom properties declared at :root, a wrapper div, or the SvGrid element cascade into the grid, controlling light and dark themes and comfortable versus compact row density.](/docs-media/gs-theming.svg)\r\n\r\n## Token surface\r\n\r\nThe 20-odd tokens the renderer reads:\r\n\r\n| Token | What it paints |\r\n| -------------------------------- | ------------------------------------------- |\r\n| `--sg-bg` | Cell background |\r\n| `--sg-fg` | Cell text |\r\n| `--sg-muted` | Secondary text (footers, subtitles) |\r\n| `--sg-border` | Cell + header borders |\r\n| `--sg-header-bg` / `--sg-header-fg` | Header row |\r\n| `--sg-row-alt-bg` | Zebra rows |\r\n| `--sg-row-hover-bg` | Row + cell hover |\r\n| `--sg-row-height` | Row height |\r\n| `--sg-selection-bg` | Selected cell / row tint |\r\n| `--sg-accent` | Sort indicator, focus ring, primary buttons |\r\n| `--sg-focus-ring` | Keyboard focus outline |\r\n| `--sg-input-bg` / `--sg-input-border` | Inline editor + filter inputs |\r\n| `--sg-pill-active` / `-fg` | \"Active\" status pills |\r\n| `--sg-pill-pending` / `-fg` | \"Pending\" status pills |\r\n| `--sg-pill-inactive` / `-fg` | \"Inactive\" status pills |\r\n| `--sg-scrollbar-*` (10 tokens) | Custom-painted scrollbars |\r\n\r\n## Light + dark via `data-theme`\r\n\r\nThe gallery flips themes by writing `dark` or `light` to\r\n`html[data-theme]`. Every token redeclares under that selector:\r\n\r\n```css\r\n:root {\r\n --sg-bg: #ffffff;\r\n --sg-fg: #0f172a;\r\n --sg-border: #e2e8f0;\r\n --sg-header-bg: #f1f5f9;\r\n --sg-row-alt-bg: #f8fafc;\r\n --sg-row-hover-bg: #eef2ff;\r\n --sg-accent: #2563eb;\r\n}\r\n\r\nhtml[data-theme='dark'] {\r\n --sg-bg: #0f172a;\r\n --sg-fg: #f1f5f9;\r\n --sg-border: #334155;\r\n --sg-header-bg: #1e2433;\r\n --sg-row-alt-bg: #1b2230;\r\n --sg-row-hover-bg: #232b3c;\r\n --sg-accent: #3b82f6;\r\n color-scheme: dark;\r\n}\r\n```\r\n\r\nToggling is one line in the app shell:\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n let theme = $state<'light' | 'dark'>('dark')\r\n $effect(() => document.documentElement.setAttribute('data-theme', theme))\r\n</script>\r\n\r\n<button onclick={() => (theme = theme === 'dark' ? 'light' : 'dark')}>\r\n Toggle theme\r\n</button>\r\n```\r\n\r\n## Per-instance theming\r\n\r\nBecause the tokens are plain custom properties they cascade. To style a\r\nsingle grid, wrap it in a `<div>` that sets its own values:\r\n\r\n```svelte\r\n<div style=\"--sg-bg: #fff8f0; --sg-accent: #db2777;\">\r\n <SvGrid {data} {columns} features={features} />\r\n</div>\r\n```\r\n\r\nThe [`10-custom-cells-and-themes`](../../examples/src/demos/10-custom-cells-and-themes.svelte)\r\ndemo applies three full palettes (light / dark / high-contrast) this way.\r\n\r\n<div data-docs-demo=\"10-custom-cells-and-themes\" data-height=\"520\"></div>\r\n\r\n## Density\r\n\r\nTwo ways:\r\n\r\n1. **Set `rowHeight` on `<SvGrid>`**. Numeric, in pixels. Drives the\r\n row height and the active-cell hit box.\r\n\r\n ```svelte\r\n <SvGrid {data} {columns} features={features} rowHeight={28} />\r\n ```\r\n\r\n2. **Override `--sg-row-height` on a wrapper.** Same effect, with the\r\n token shape if you'd rather express density in CSS.\r\n\r\n ```css\r\n .compact { --sg-row-height: 28px; }\r\n .comfortable { --sg-row-height: 48px; }\r\n ```\r\n\r\nA user-facing \"density selector\" is half a dozen lines:\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n let density = $state<'compact' | 'normal' | 'comfortable'>('normal')\r\n const height = $derived(\r\n density === 'compact' ? 28 : density === 'comfortable' ? 48 : 36,\r\n )\r\n</script>\r\n\r\n<select bind:value={density}>\r\n <option value=\"compact\">Compact</option>\r\n <option value=\"normal\">Normal</option>\r\n <option value=\"comfortable\">Comfortable</option>\r\n</select>\r\n\r\n<SvGrid {data} {columns} features={features} rowHeight={height} />\r\n```\r\n\r\n## Sizing the grid\r\n\r\nThe wrapper renders inside whatever container you give it. The\r\n`containerHeight` prop sets the scrollable shell height:\r\n\r\n```svelte\r\n<!-- Numeric: px -->\r\n<SvGrid {data} {columns} features={features} containerHeight={520} />\r\n\r\n<!-- String: passed through to CSS -->\r\n<SvGrid {data} {columns} features={features} containerHeight=\"100%\" />\r\n<SvGrid {data} {columns} features={features} containerHeight=\"auto\" />\r\n```\r\n\r\nFor a flex-grow layout the canonical recipe is:\r\n\r\n```svelte\r\n<div class=\"flex flex-col h-screen\">\r\n <header>…</header>\r\n <div class=\"flex-1 min-h-0\">\r\n <SvGrid {data} {columns} features={features} containerHeight=\"100%\" />\r\n </div>\r\n</div>\r\n```\r\n\r\nThe `min-h-0` is the bit that bites. Flex children default to\r\n`min-height: auto`, which prevents the inner scroll container from\r\nshrinking, which makes the whole page scroll instead of the grid.\r\n\r\n## Full Tailwind integration\r\n\r\nIf your app uses Tailwind, see [Tailwind integration](../help/tailwind.md)\r\nfor: install + PostCSS config, `@custom-variant` so the `dark:`\r\nmodifier follows `data-theme`, the override hooks for the stable\r\n`.sv-grid-*` class names, and the anti-patterns (don't `@apply` inside\r\ngrid selectors, don't put utility classes on grid children, don't\r\nfight column widths in CSS).\r\n"
3017
+ "markdown": "# 5. Theme and density\r\n\r\n> Step 5 of 6 · [← Features](./4-features.md) · [Next: Going to production →](./6-going-to-production.md)\r\n\r\n## Start with a preset\r\n\r\nBefore hand-writing any tokens: 20 design-system presets ship with the package,\r\neach a single stylesheet with a full light + dark palette. One import re-themes\r\nthe whole grid.\r\n\r\n```ts\r\nimport '@svgrid/grid/themes/shadcn.css'\r\n```\r\n\r\nAvailable: `ember` (SvGrid's own look), `shadcn`, `tailwind`, `material`,\r\n`fluent`, `carbon`, `antd`, `bootstrap`, `atlassian`, `salesforce`, `sap`,\r\n`github`, `linear`, `notion`, `vercel`, `excel`, `nord`, `dracula`,\r\n`catppuccin`, `ag-alpine`.\r\n\r\nEach preset defines every token the grid and the UI components read - including\r\nthe semantic status colors (`--sg-danger`, `--sg-success`, `--sg-warning`,\r\n`--sg-info`) and the focus ring, which follows the preset's accent. Presets\r\nflip with `data-theme=\"dark\"` automatically (see below).\r\n\r\nOverride individual tokens after the import to adjust a preset, or skip presets\r\nentirely and declare the tokens yourself - that is the rest of this page.\r\n\r\n## Declaring tokens yourself\r\n\r\nThe render component (`<SvGrid>`) ships its own scoped styles. You\r\nre-theme it by declaring `--sg-*` CSS custom properties at any level\r\nabove the grid - `:root` for the whole app, a wrapper `<div>` for one\r\ninstance, or directly on the `<SvGrid>` element itself.\r\n\r\n![The --sg-* CSS custom properties declared at :root, a wrapper div, or the SvGrid element cascade into the grid, controlling light and dark themes and comfortable versus compact row density.](/docs-media/gs-theming.svg)\r\n\r\n## Token surface\r\n\r\nThe 20-odd tokens the renderer reads:\r\n\r\n| Token | What it paints |\r\n| -------------------------------- | ------------------------------------------- |\r\n| `--sg-bg` | Cell background |\r\n| `--sg-fg` | Cell text |\r\n| `--sg-muted` | Secondary text (footers, subtitles) |\r\n| `--sg-border` | Cell + header borders |\r\n| `--sg-header-bg` / `--sg-header-fg` | Header row |\r\n| `--sg-row-alt-bg` | Zebra rows |\r\n| `--sg-row-hover-bg` | Row + cell hover |\r\n| `--sg-selection-bg` | Selected cell / row tint |\r\n| `--sg-accent` | Sort indicator, focus ring, primary buttons |\r\n| `--sg-focus-ring` | Keyboard focus outline |\r\n| `--sg-input-bg` / `--sg-input-border` | Inline editor + filter inputs |\r\n| `--sg-pill-active` / `-fg` | \"Active\" status pills |\r\n| `--sg-pill-pending` / `-fg` | \"Pending\" status pills |\r\n| `--sg-pill-inactive` / `-fg` | \"Inactive\" status pills |\r\n| `--sg-scrollbar-*` (10 tokens) | Custom-painted scrollbars |\r\n\r\n## Light + dark via `data-theme`\r\n\r\nThe gallery flips themes by writing `dark` or `light` to\r\n`html[data-theme]`. Every token redeclares under that selector:\r\n\r\n```css\r\n:root {\r\n --sg-bg: #ffffff;\r\n --sg-fg: #0f172a;\r\n --sg-border: #e2e8f0;\r\n --sg-header-bg: #f1f5f9;\r\n --sg-row-alt-bg: #f8fafc;\r\n --sg-row-hover-bg: #eef2ff;\r\n --sg-accent: #2563eb;\r\n}\r\n\r\nhtml[data-theme='dark'] {\r\n --sg-bg: #0f172a;\r\n --sg-fg: #f1f5f9;\r\n --sg-border: #334155;\r\n --sg-header-bg: #1e2433;\r\n --sg-row-alt-bg: #1b2230;\r\n --sg-row-hover-bg: #232b3c;\r\n --sg-accent: #3b82f6;\r\n color-scheme: dark;\r\n}\r\n```\r\n\r\nToggling is one line in the app shell:\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n let theme = $state<'light' | 'dark'>('dark')\r\n $effect(() => document.documentElement.setAttribute('data-theme', theme))\r\n</script>\r\n\r\n<button onclick={() => (theme = theme === 'dark' ? 'light' : 'dark')}>\r\n Toggle theme\r\n</button>\r\n```\r\n\r\n## Per-instance theming\r\n\r\nBecause the tokens are plain custom properties they cascade. To style a\r\nsingle grid, wrap it in a `<div>` that sets its own values:\r\n\r\n```svelte\r\n<div style=\"--sg-bg: #fff8f0; --sg-accent: #db2777;\">\r\n <SvGrid {data} {columns} features={features} />\r\n</div>\r\n```\r\n\r\nThe [`10-custom-cells-and-themes`](../../examples/src/demos/10-custom-cells-and-themes.svelte)\r\ndemo applies three full palettes (light / dark / high-contrast) this way.\r\n\r\n<div data-docs-demo=\"10-custom-cells-and-themes\" data-height=\"520\"></div>\r\n\r\n## Density\r\n\r\nDensity is a **prop, not a token**. The virtualizer has to know each\r\nrow's height as a number before it can position rows, so it cannot be\r\nresolved from CSS - the grid writes the height as an inline style on\r\nevery row, which would override a stylesheet rule anyway.\r\n\r\nSet `rowHeight` on `<SvGrid>`. Numeric, in pixels; drives the row\r\nheight and the active-cell hit box. The default is `30`.\r\n\r\n```svelte\r\n<SvGrid {data} {columns} features={features} rowHeight={28} />\r\n```\r\n\r\nCommon steps: `28` compact, `30` normal, `46`-`48` comfortable. For\r\nrows whose height depends on their content, set `autoRowHeight`\r\ninstead and the grid measures each row.\r\n\r\nA user-facing \"density selector\" is half a dozen lines:\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n let density = $state<'compact' | 'normal' | 'comfortable'>('normal')\r\n const height = $derived(\r\n density === 'compact' ? 28 : density === 'comfortable' ? 48 : 30,\r\n )\r\n</script>\r\n\r\n<select bind:value={density}>\r\n <option value=\"compact\">Compact</option>\r\n <option value=\"normal\">Normal</option>\r\n <option value=\"comfortable\">Comfortable</option>\r\n</select>\r\n\r\n<SvGrid {data} {columns} features={features} rowHeight={height} />\r\n```\r\n\r\n## Sizing the grid\r\n\r\nThe wrapper renders inside whatever container you give it. The\r\n`containerHeight` prop sets the scrollable shell height:\r\n\r\n```svelte\r\n<!-- Numeric: px -->\r\n<SvGrid {data} {columns} features={features} containerHeight={520} />\r\n\r\n<!-- String: passed through to CSS -->\r\n<SvGrid {data} {columns} features={features} containerHeight=\"100%\" />\r\n<SvGrid {data} {columns} features={features} containerHeight=\"auto\" />\r\n```\r\n\r\nFor a flex-grow layout the canonical recipe is:\r\n\r\n```svelte\r\n<div class=\"flex flex-col h-screen\">\r\n <header>…</header>\r\n <div class=\"flex-1 min-h-0\">\r\n <SvGrid {data} {columns} features={features} containerHeight=\"100%\" />\r\n </div>\r\n</div>\r\n```\r\n\r\nThe `min-h-0` is the bit that bites. Flex children default to\r\n`min-height: auto`, which prevents the inner scroll container from\r\nshrinking, which makes the whole page scroll instead of the grid.\r\n\r\n## Full Tailwind integration\r\n\r\nIf your app uses Tailwind, see [Tailwind integration](../help/tailwind.md)\r\nfor: install + PostCSS config, `@custom-variant` so the `dark:`\r\nmodifier follows `data-theme`, the override hooks for the stable\r\n`.sv-grid-*` class names, and the anti-patterns (don't `@apply` inside\r\ngrid selectors, don't put utility classes on grid children, don't\r\nfight column widths in CSS).\r\n"
3006
3018
  },
3007
3019
  {
3008
3020
  "slug": "getting-started/6-going-to-production",
@@ -3020,7 +3032,7 @@ export const docs = [
3020
3032
  "slug": "help/accessibility",
3021
3033
  "path": "docs/help/accessibility.md",
3022
3034
  "title": "Accessibility",
3023
- "markdown": "# Accessibility\n\nSvGrid implements the WAI-ARIA 1.2 grid pattern with full keyboard\nnavigation and a screen-reader announcement layer. This page documents\nexactly what the grid does, where the responsibility line sits between\nthe library and your code, and how to verify conformance.\n\n![An ARIA grid whose rows carry aria-rowindex and cells carry aria-colindex, a roving tabindex marking the active cell that arrow keys move, and an aria-live region announcing the new cell to a screen reader.](/docs-media/grid-a11y.svg)\n\nLive demo - high-contrast toggle, `aria-live` log, and a focus-trap\nwalk-through:\n\n<div data-docs-demo=\"17-accessibility\" data-height=\"520\"></div>\n\n## TL;DR\n\n| Standard | Status |\n| ----------------------- | ---------------------------------------------------------------------- |\n| WAI-ARIA 1.2 grid pattern | Implemented (see [Roles & properties](#roles--properties)). |\n| WCAG 2.1 AA | The grid meets AA for the default theme + tokens; your custom theme determines pass/fail of contrast. |\n| Keyboard navigation | Full coverage; see [Keyboard map](#keyboard-map). |\n| Screen-reader announcements | Cell + selection + edit announcements via a single `aria-live=polite` region. |\n| Reduced motion | Honored: scroll animations + chevron transitions disable when `prefers-reduced-motion: reduce`. |\n| Forced colors / high contrast | Supported: borders + focus rings use `currentColor`; no hardcoded `border-color`. |\n\n## Roles & properties\n\n| Element | Role | Notable attributes |\n| ------------------------------ | -------------- | --------------------------------------------------------------------------------------------------- |\n| `<table>` root | `grid` | `aria-rowcount`, `aria-colcount` |\n| `<thead>`, `<tbody>` | `rowgroup` | |\n| Every `<tr>` (header + body) | `row` | `aria-rowindex` (1-based); selected rows get `aria-selected=\"true\"` |\n| Header `<th>` | `columnheader` | `aria-sort` (`\"ascending\"` / `\"descending\"` / `\"none\"`) |\n| Body `<td>` | `gridcell` | `aria-colindex`, `aria-selected`, `aria-readonly` (when the column is non-editable) |\n| Active cell | `gridcell` | `tabindex=\"0\"`; every other cell `tabindex=\"-1\"` (roving tabindex pattern) |\n| Filter popover | `dialog` | `aria-label` derived from the column header |\n| Cell editor input | `textbox` / `combobox` / `checkbox` (depending on `editorType`) | `aria-label` mirrors the column header |\n| Live announcement region | none (uses `aria-live=\"polite\"` on a visually-hidden `<div>`) | |\n\nThe grid never sets `role=\"presentation\"` on table elements - screen\nreaders receive a fully-structured grid.\n\n## Keyboard map\n\nStandard ARIA grid navigation, plus a handful of grid-specific\nshortcuts:\n\n### Navigation\n\n| Key | Action |\n| ------------------- | --------------------------------------------------------------------- |\n| Tab | Move focus *out* of the grid to the next focusable element. |\n| Shift+Tab | Move focus *into* the grid from the previous focusable element. |\n| Arrow Up/Down | Move active cell one row. |\n| Arrow Left/Right | Move active cell one column. |\n| Home | Active cell → first column in the row. |\n| End | Active cell → last column in the row. |\n| Ctrl/Cmd + Home | Active cell → top-left of the grid. |\n| Ctrl/Cmd + End | Active cell → bottom-right. |\n| Page Up / Page Down | Scroll a viewport's worth of rows. |\n\n### Selection (when `rowSelectionFeature` is on)\n\n| Key | Action |\n| ------------------------- | --------------------------------------------------------------- |\n| Space | Toggle the active row's selection. |\n| Ctrl/Cmd + A | Select all rows on the current page. |\n| Shift + Arrow Up/Down | Extend the row selection. |\n| Ctrl/Cmd + Click on a row | Toggle that row's selection without affecting others. |\n\n### Sort + filter\n\n| Key | Action |\n| -------------------------------- | -------------------------------------------------------- |\n| Enter (on a header) | Toggle sort: `none → asc → desc → none`. |\n| Shift + Enter (on a header) | Add to multi-sort. |\n| Alt + Down (on a header) | Open the filter menu (when `filterMode='menu'`). |\n| Escape (in a filter menu) | Close the menu. |\n\n### Editing\n\n| Key | Action |\n| --------------------------- | ----------------------------------------------------- |\n| Enter / F2 / double-click | Enter edit mode on the active cell. |\n| Type any character | Start editing with that character as the first input. |\n| Enter / Tab (while editing) | Commit and move to the next cell / row. |\n| Escape (while editing) | Cancel; revert the cell. |\n| Delete / Backspace | Clear the active cell (for cells whose editor supports clearing). |\n\n### Tree rows (when you build them)\n\nThe keyboard handler in [Tree rows](./rows/tree-rows.md) adds:\n\n| Key | Action (active cell in name column) |\n| ------------------- | ----------------------------------- |\n| Arrow Right | Expand a collapsed node. |\n| Arrow Left | Collapse an expanded node. |\n| Enter / Space | Toggle. |\n\n## Screen-reader announcements\n\nThe grid maintains a single `aria-live=\"polite\"` region that emits:\n\n- Cell content when the active cell moves (so VoiceOver/JAWS reads\n \"Customer column, row 14, Acme Corp\").\n- Selection state changes (`\"row 14 selected, 3 of 12 rows selected\"`).\n- Edit commits (`\"saved 19.95\"`) - useful for users who can't see the\n cell's visual state.\n- Sort changes (`\"sorted by Customer ascending\"`).\n- Filter changes (`\"3 of 124 rows match\"`).\n\nYou can replace the announcer with your own by passing\n`announcer={(message) => ...}` to `<SvGrid>` if you have a unified\ntoast system.\n\n## Focus management\n\n- The grid uses a **roving tabindex**: at most one cell at a time has\n `tabindex=\"0\"`, every other has `tabindex=\"-1\"`. This puts the grid\n in the tab order exactly once.\n- The \"active cell\" is the focused cell. It's tracked through every\n navigation, editing, and selection action.\n- Editing transfers focus to the editor `<input>`; committing returns\n focus to the cell.\n- Modals (filter menu, save dialog) use a focus trap that returns\n focus to the originating header/cell on close.\n\n## High-contrast / forced-colors mode\n\nWindows High Contrast mode (`forced-colors: active`) is respected. The\ngrid uses `currentColor` for every border and focus ring, so the\nsystem's color tokens take over without overrides leaking. We test\nagainst the [W3C forced-colors test page](https://web.dev/articles/forced-colors).\n\n## Reduced motion\n\nWhen `prefers-reduced-motion: reduce` matches:\n\n- Smooth-scroll calls become `behavior: 'instant'`.\n- Chevron rotations (used in tree demos) are instant rather than 160 ms\n ease.\n- Sparkline + KPI bar transitions are disabled.\n\nYou don't need to opt in - the grid checks the media query on every\nanimation entry point.\n\n## What you're responsible for\n\nThe grid can't know:\n\n- **Contrast** of your custom CSS variables. Use the [Tailwind\n integration](./tailwind.md) page's contrast notes when picking\n `--sg-fg` / `--sg-bg` pairs.\n- **Labels** for cells whose content is purely visual (e.g. a status\n pill that's just a coloured dot). Set `aria-label` on the cell\n content yourself.\n- **Reading order** of header groups. The grid emits group headers\n with `aria-colspan` correctly, but if your group label is \"Q1\"\n alone, screen readers say \"Q1\" - consider \"Q1 2025\" to give context.\n\n## How to verify\n\n1. **Keyboard sweep.** Unplug your mouse. Tab in, navigate every cell,\n sort, filter, edit, undo. If any action is unreachable, file an issue.\n2. **NVDA + VoiceOver.** Each makes different choices about announcement\n verbosity. Test both.\n3. **Lighthouse accessibility audit.** Default theme passes 100. If\n your custom theme drops the score, the deltas are virtually always\n contrast issues you control.\n4. **axe-core in your e2e suite.**\n ```ts\n import { injectAxe, checkA11y } from 'axe-playwright'\n await injectAxe(page)\n await checkA11y(page, '.sv-grid-shell', { detailedReport: false })\n ```\n\n## See also\n\n- [Browser support](./browser-support.md) - the `ResizeObserver` /\n Pointer Events floor every assistive-tech tool relies on.\n- [Tailwind integration](./tailwind.md) - the `--sg-*` tokens that\n control contrast.\n- [Testing your grid](./testing.md) - includes an axe-core recipe.\n\n## Frequently asked questions\n\n### Is SvGrid accessible / WCAG compliant?\n\nSvGrid implements the WAI-ARIA 1.2 grid pattern: `role=\"grid\"` structure, full\nkeyboard navigation (arrows, Home/End, Page Up/Down, Ctrl+Home/End), a focus\nring on the active cell, and an `aria-live` announcement layer. Final WCAG\nconformance also depends on your own cell content and color choices - this page\ndocuments exactly where that line sits.\n\n### Does SvGrid work with screen readers?\n\nYes. The grid exposes proper roles and emits `aria-live` announcements for\nsorting, filtering, and selection changes, so NVDA, JAWS, and VoiceOver can\nread the grid state. Because it renders real DOM (not canvas), the content is\nalso selectable and machine-readable.\n\n### How do I verify accessibility in my app?\n\nRun axe-core against the rendered grid (recipe in the testing guide) and test\nkeyboard-only navigation. Pair it with the high-contrast focus toggle and the\n`--sg-*` contrast tokens to meet your target contrast ratios.\n"
3035
+ "markdown": "# Accessibility\r\n\r\nSvGrid implements the WAI-ARIA 1.2 grid pattern with full keyboard\r\nnavigation and a screen-reader announcement layer. This page documents\r\nexactly what the grid does, where the responsibility line sits between\r\nthe library and your code, and how to verify conformance.\r\n\r\n![An ARIA grid whose rows carry aria-rowindex and cells carry aria-colindex, a roving tabindex marking the active cell that arrow keys move, and an aria-live region announcing the new cell to a screen reader.](/docs-media/grid-a11y.svg)\r\n\r\nLive demo - high-contrast toggle, `aria-live` log, and a focus-trap\r\nwalk-through:\r\n\r\n<div data-docs-demo=\"17-accessibility\" data-height=\"520\"></div>\r\n\r\n## TL;DR\r\n\r\n| Standard | Status |\r\n| ----------------------- | ---------------------------------------------------------------------- |\r\n| WAI-ARIA 1.2 grid pattern | Implemented (see [Roles & properties](#roles--properties)). |\r\n| WCAG 2.1 AA | Structure is audited by `axe-core` in CI. Contrast is enforced in CI for **all 20 built-in presets, light and dark**; a custom theme is yours to verify. |\r\n| Keyboard navigation | Full coverage; see [Keyboard map](#keyboard-map). |\r\n| Screen-reader announcements | Filter results and bulk selection changes, via a single `aria-live=polite` region. See [What the grid announces](#what-the-grid-announces). |\r\n| Reduced motion | Honored: scroll animations + chevron transitions disable when `prefers-reduced-motion: reduce`. |\r\n| Forced colors / high contrast | Supported: borders + focus rings use `currentColor`; no hardcoded `border-color`. |\r\n\r\n## Roles & properties\r\n\r\n| Element | Role | Notable attributes |\r\n| ------------------------------ | -------------- | --------------------------------------------------------------------------------------------------- |\r\n| `<table>` root | `grid` | `aria-rowcount`, `aria-colcount` |\r\n| `<thead>`, `<tbody>` | `rowgroup` | |\r\n| Every `<tr>` (header + body) | `row` | `aria-rowindex` (1-based); selected rows get `aria-selected=\"true\"` |\r\n| Header `<th>` | `columnheader` | `aria-sort` (`\"ascending\"` / `\"descending\"` / `\"none\"`) |\r\n| Body `<td>` | `gridcell` | `aria-colindex`, `aria-selected`, `aria-readonly` (when the column is non-editable) |\r\n| Active cell | `gridcell` | `tabindex=\"0\"`; every other cell `tabindex=\"-1\"` (roving tabindex pattern) |\r\n| Filter popover | `dialog` | `aria-label` derived from the column header |\r\n| Cell editor input | `textbox` / `combobox` / `checkbox` (depending on `editorType`) | `aria-label` mirrors the column header |\r\n| Live announcement region | none (uses `aria-live=\"polite\"` on a visually-hidden `<div>`) | |\r\n\r\nThe grid never sets `role=\"presentation\"` on table elements - screen\r\nreaders receive a fully-structured grid.\r\n\r\n## Keyboard map\r\n\r\nStandard ARIA grid navigation, plus a handful of grid-specific\r\nshortcuts:\r\n\r\n### Navigation\r\n\r\n| Key | Action |\r\n| ------------------- | --------------------------------------------------------------------- |\r\n| Tab | Move focus *out* of the grid to the next focusable element. |\r\n| Shift+Tab | Move focus *into* the grid from the previous focusable element. |\r\n| Arrow Up/Down | Move active cell one row. |\r\n| Arrow Left/Right | Move active cell one column. |\r\n| Home | Active cell → first column in the row. |\r\n| End | Active cell → last column in the row. |\r\n| Ctrl/Cmd + Home | Active cell → top-left of the grid. |\r\n| Ctrl/Cmd + End | Active cell → bottom-right. |\r\n| Page Up / Page Down | Scroll a viewport's worth of rows. |\r\n\r\n### Selection (when `rowSelectionFeature` is on)\r\n\r\n| Key | Action |\r\n| ------------------------- | --------------------------------------------------------------- |\r\n| Space | Toggle the active row's selection. |\r\n| Ctrl/Cmd + A | Select all rows on the current page. |\r\n| Shift + Arrow Up/Down | Extend the row selection. |\r\n| Ctrl/Cmd + Click on a row | Toggle that row's selection without affecting others. |\r\n\r\n### Sort + filter\r\n\r\n| Key | Action |\r\n| -------------------------------- | -------------------------------------------------------- |\r\n| Enter (on a header) | Toggle sort: `none → asc → desc → none`. |\r\n| Shift + Enter (on a header) | Add to multi-sort. |\r\n| Alt + Down (on a header) | Open the filter menu (when `filterMode='menu'`). |\r\n| Escape (in a filter menu) | Close the menu. |\r\n\r\n### Editing\r\n\r\n| Key | Action |\r\n| --------------------------- | ----------------------------------------------------- |\r\n| Enter / F2 / double-click | Enter edit mode on the active cell. |\r\n| Type any character | Start editing with that character as the first input. |\r\n| Enter / Tab (while editing) | Commit and move to the next cell / row. |\r\n| Escape (while editing) | Cancel; revert the cell. |\r\n| Delete / Backspace | Clear the active cell (for cells whose editor supports clearing). |\r\n\r\n### Tree rows (when you build them)\r\n\r\nThe keyboard handler in [Tree rows](./rows/tree-rows.md) adds:\r\n\r\n| Key | Action (active cell in name column) |\r\n| ------------------- | ----------------------------------- |\r\n| Arrow Right | Expand a collapsed node. |\r\n| Arrow Left | Collapse an expanded node. |\r\n| Enter / Space | Toggle. |\r\n\r\n## Screen-reader announcements\r\n\r\nThe grid shares one visually-hidden `aria-live=\"polite\"` region with the rest of\r\nthe component library, and is deliberately sparing about what it puts there.\r\n\r\n### What the grid announces\r\n\r\n| Event | What is said |\r\n| --- | --- |\r\n| A filter changes the matching rows | `\"12 of 250 rows match the current filters\"`, or `\"No rows match the current filters\"` |\r\n| Every filter is cleared | `\"Filters cleared, showing all 250 rows\"` |\r\n| A bulk selection change | `\"250 rows selected\"` / `\"Selection cleared\"` |\r\n\r\nFilter announcements are debounced by 400 ms, so typing in the search box\r\nannounces the count you stopped on rather than one for every prefix. They are\r\nalso skipped entirely under `externalFilter`, because there the server decides\r\nwhat matched and the local count would describe only the page in hand.\r\n\r\n### What it deliberately does not announce\r\n\r\nThis is the more important half. A live region is not the only way a screen\r\nreader learns something, and announcing what the accessibility tree already\r\ncarries makes the grid talk over itself:\r\n\r\n- **The cell you moved to.** Focus moves there (roving tabindex), so the reader\r\n announces the cell, its column header and its row position natively.\r\n- **Sort state.** Carried by `aria-sort` on the column header, read when focus\r\n lands on it.\r\n- **Whether the row you are on is selected.** Carried by `aria-selected`.\r\n- **Selecting or deselecting a single row.** The focused row is announced with\r\n its new selected state; adding `\"1 row selected\"` on top would be a second,\r\n redundant utterance. Only changes of more than one row, which cannot have come\r\n from a single focus move, are announced.\r\n\r\n### Translating them\r\n\r\nAnnouncements are full sentences, so unlike the single-word labels elsewhere in\r\n`localeText` they take `{placeholders}` and you control the word order:\r\n\r\n```svelte\r\n<SvGrid\r\n localization={{\r\n text: {\r\n announceFilterResults: '{visible} van de {total} rijen komen overeen',\r\n announceNoMatches: 'Geen rijen komen overeen',\r\n announceFiltersCleared: 'Filters gewist, alle {total} rijen worden getoond',\r\n announceRowsSelected: '{count} rijen geselecteerd',\r\n announceSelectionCleared: 'Selectie gewist',\r\n },\r\n }}\r\n/>\r\n```\r\n\r\nAn unknown placeholder is left in the output rather than blanked, so a typo in\r\nan override is visible instead of silently eating the number.\r\n\r\n## Focus management\r\n\r\n- The grid uses a **roving tabindex**: at most one cell at a time has\r\n `tabindex=\"0\"`, every other has `tabindex=\"-1\"`. This puts the grid\r\n in the tab order exactly once.\r\n- The \"active cell\" is the focused cell. It's tracked through every\r\n navigation, editing, and selection action.\r\n- Editing transfers focus to the editor `<input>`; committing returns\r\n focus to the cell.\r\n- Modals (filter menu, save dialog) use a focus trap that returns\r\n focus to the originating header/cell on close.\r\n\r\n## High-contrast / forced-colors mode\r\n\r\nWindows High Contrast mode (`forced-colors: active`) is respected. The\r\ngrid uses `currentColor` for every border and focus ring, so the\r\nsystem's color tokens take over without overrides leaking. We test\r\nagainst the [W3C forced-colors test page](https://web.dev/articles/forced-colors).\r\n\r\n## Reduced motion\r\n\r\nWhen `prefers-reduced-motion: reduce` matches:\r\n\r\n- Smooth-scroll calls become `behavior: 'instant'`.\r\n- Chevron rotations (used in tree demos) are instant rather than 160 ms\r\n ease.\r\n- Sparkline + KPI bar transitions are disabled.\r\n\r\nYou don't need to opt in - the grid checks the media query on every\r\nanimation entry point.\r\n\r\n## What you're responsible for\r\n\r\nThe grid can't know:\r\n\r\n- **Contrast** of your custom CSS variables. Use the [Tailwind\r\n integration](./tailwind.md) page's contrast notes when picking\r\n `--sg-fg` / `--sg-bg` pairs.\r\n- **Labels** for cells whose content is purely visual (e.g. a status\r\n pill that's just a coloured dot). Set `aria-label` on the cell\r\n content yourself.\r\n- **Reading order** of header groups. The grid emits group headers\r\n with `aria-colspan` correctly, but if your group label is \"Q1\"\r\n alone, screen readers say \"Q1\" - consider \"Q1 2025\" to give context.\r\n- **The wording of validation messages.** A cell failing a column's\r\n `validate` hook is marked `aria-invalid` and the returned string is\r\n carried to assistive technology, so return a message that says how to\r\n fix it (\"Score must be at least 90\"), not just that something is\r\n wrong (\"Invalid\"). Returning `false` marks the cell invalid with no\r\n message at all, which leaves a screen-reader user without a reason.\r\n\r\n## How to verify\r\n\r\n1. **Keyboard sweep.** Unplug your mouse. Tab in, navigate every cell,\r\n sort, filter, edit, undo. If any action is unreachable, file an issue.\r\n2. **NVDA + VoiceOver.** Each makes different choices about announcement\r\n verbosity. Test both.\r\n3. **Lighthouse accessibility audit.** Run it against your own build. Most\r\n deltas are contrast issues in your theme rather than grid markup, so\r\n check those first.\r\n\r\n > We run `axe-core` against a rendered grid in CI (`a11y.axe.test.ts`),\r\n > covering the plain grid, the filter row, row selection and pagination.\r\n > That suite runs in jsdom, which does no layout, so axe's own\r\n > colour-contrast rule is disabled there.\r\n >\r\n > Contrast is covered separately and more thoroughly: `themes/contrast.test.ts`\r\n > computes WCAG ratios for **every built-in preset in both light and dark**\r\n > and fails the build below AA. Body text, secondary text, header text, text\r\n > on zebra / hovered / selected rows, and text on accent-filled controls all\r\n > have to clear 4.5:1, and the accent has to clear 3:1 where it signals focus\r\n > or selection. So the presets are checked exhaustively rather than whichever\r\n > one a browser test happened to load. What is still yours to verify is a\r\n > **custom** theme.\r\n4. **axe-core in your e2e suite.**\r\n ```ts\r\n import { injectAxe, checkA11y } from 'axe-playwright'\r\n await injectAxe(page)\r\n await checkA11y(page, '.sv-grid-shell', { detailedReport: false })\r\n ```\r\n\r\n## See also\r\n\r\n- [Browser support](./browser-support.md) - the `ResizeObserver` /\r\n Pointer Events floor every assistive-tech tool relies on.\r\n- [Tailwind integration](./tailwind.md) - the `--sg-*` tokens that\r\n control contrast.\r\n- [Testing your grid](./testing.md) - includes an axe-core recipe.\r\n\r\n## Frequently asked questions\r\n\r\n### Is SvGrid accessible / WCAG compliant?\r\n\r\nSvGrid implements the WAI-ARIA 1.2 grid pattern: `role=\"grid\"` structure, full\r\nkeyboard navigation (arrows, Home/End, Page Up/Down, Ctrl+Home/End), a focus\r\nring on the active cell, and an `aria-live` announcement layer. Final WCAG\r\nconformance also depends on your own cell content and color choices - this page\r\ndocuments exactly where that line sits.\r\n\r\n### Does SvGrid work with screen readers?\r\n\r\nYes. The grid exposes proper roles and emits `aria-live` announcements for\r\nsorting, filtering, and selection changes, so NVDA, JAWS, and VoiceOver can\r\nread the grid state. Because it renders real DOM (not canvas), the content is\r\nalso selectable and machine-readable.\r\n\r\n### How do I verify accessibility in my app?\r\n\r\nRun axe-core against the rendered grid (recipe in the testing guide) and test\r\nkeyboard-only navigation. Pair it with the high-contrast focus toggle and the\r\n`--sg-*` contrast tokens to meet your target contrast ratios.\r\n"
3024
3036
  },
3025
3037
  {
3026
3038
  "slug": "help/agents",
@@ -3038,7 +3050,7 @@ export const docs = [
3038
3050
  "slug": "help/ai-toolkit",
3039
3051
  "path": "docs/help/ai-toolkit.md",
3040
3052
  "title": "AI Toolkit",
3041
- "markdown": "# AI Toolkit\n\nEverything SvGrid ships for building with language models, in one place.\nThe toolkit spans two axes: **AI inside your running app** (helpers your\nusers invoke - natural-language filter, smart fill, summarise, classify)\nand **AI inside your editor** (the MCP server + grounding files that make\nClaude, Cursor, and friends write correct SvGrid code).\n\nNothing here bundles a model. SvGrid is **model-agnostic and\nbring-your-own-key**: you register one adapter and keep full control of\nmodel choice, routing, and what data leaves the browser.\n\n<div data-docs-demo=\"51-ai-assistant\" data-height=\"560\"></div>\n\n## The two surfaces\n\n| | AI in your app (runtime) | AI in your editor (build time) |\n| --- | --- | --- |\n| **Who invokes it** | your end users | you and your coding agent |\n| **What it does** | filter / fill / summarise / classify / export the live grid | scaffold columns, generate CRUD screens, answer API questions |\n| **Package** | `@svgrid/enterprise` (`api.ai.*`) | `@sv-grid/mcp-server`, `@svgrid/mcp`, grounding files |\n| **Needs a model key** | yes - the one you register | no - your agent brings its own |\n| **Deep dive** | [AI assistant](./ai.md) | [MCP server](./mcp-server.md) · [LLM grounding](./llm-grounding.md) |\n\nMost teams use both: the MCP server to write the grid, the in-grid\nhelpers to power features inside it.\n\n## How it works\n\nThe grid never calls a model directly. Every runtime AI call routes\nthrough a single async **provider** you register once at app boot:\n\n```ts\nimport { setAIProvider, type AIProvider } from '@svgrid/grid'\n\nconst provider: AIProvider = async ({ prompt, responseFormat, signal, task }) => {\n const r = await fetch('/api/ai', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ prompt, responseFormat, task }),\n signal,\n })\n if (!r.ok) throw new Error(`AI provider returned ${r.status}`)\n return r.text()\n}\n\nsetAIProvider(provider)\n```\n\nThree design choices keep bad model output from becoming a silent wrong answer:\n\n- **Structured JSON, validated.** Helpers request `responseFormat: 'json'`\n and the grid `JSON.parse`s the reply. It strips a single markdown code\n fence automatically, so a model that wraps output in ` ```json ... ``` `\n still parses. On malformed output you get a typed error, not a silent\n wrong result.\n- **The prompt is grounded in your columns.** Before each call the grid\n embeds the live column schema (names, types, sampled values) into the\n prompt, so the model picks real field names instead of inventing them.\n- **A hallucination guard on the way back.** If a model still returns a\n column that does not exist, the clause is dropped rather than passed to\n `setFilter` - you lose a clause, never crash the page.\n\nThe provider shape is deliberately tiny (one async call, `text` or `json`\nresponse) so the same adapter drives an OpenAI `chat.completions` call, an\nAnthropic `messages` call, a self-hosted endpoint, or a server-side proxy.\nNo model client is ever bundled into your grid.\n\n> **Just evaluating?** The package ships a deterministic `mockAIProvider`\n> that returns plausible canned shapes per task. Wire it in with\n> `setAIProvider(mockAIProvider)` and every helper works end to end with\n> no key. The demo above runs on it.\n\n## In-grid helpers\n\n`installEnterprise(api)` - the same call you use for export and print -\naugments your `SvGridApi` with an `ai` namespace. Six helpers, all\nmodel-agnostic:\n\n```ts\napi.ai.filter(query, opts?) // NL sentence -> filter + sort plan\napi.ai.smartFill(opts) // 1-2 examples -> proposed column values\napi.ai.summarize(opts) // row/selection/group/all -> text + bullets\napi.ai.classify(opts) // free-text cells -> a clean enum label\napi.ai.export(query, opts?) // NL sentence -> filter + group + format, then export\napi.ai.findAnomalies(opts?) // scan a slice -> outliers + severity\n```\n\n### Natural-language filter\n\nThe highest-leverage feature: replace a dozen per-column filter operators\nwith one search box.\n\n```ts\nconst plan = await api.ai.filter('accounts losing momentum in EMEA, by NPS')\n// {\n// filters: [\n// { field: 'region', operator: 'equals', value: 'EMEA' },\n// { field: 'nps', operator: 'lessThan', value: '30' },\n// ],\n// sort: [{ field: 'nps', desc: false }],\n// rationale: 'EMEA region, low NPS, sorted ascending.',\n// }\n```\n\nBy default it **returns the plan without applying it**, so you can show a\n\"here is what I would do, accept?\" preview and surface the `rationale`.\nPass `{ apply: true }` to commit straight to the grid.\n\n### Smart fill\n\nThe killer feature for spreadsheet-style entry: type one or two examples\nin a column, let the model propose the rest.\n\n```ts\nconst result = await api.ai.smartFill({\n field: 'tier',\n examples: [\n { input: { company: 'Northwind' }, output: 'enterprise' },\n { input: { company: 'Helios' }, output: 'growth' },\n ],\n})\n// result.predictions: [{ rowIndex, value, confidence }, ...]\n```\n\nYou choose what to do with the predictions - accept-all, accept-per-cell\nwith a confidence pill, or write them onto the row for review.\n\n### Summarise, classify, export, anomalies\n\n- **`summarize`** drops a slice (row / selection / group / all) into the\n model and returns a paragraph, bullets, and the fields the story leans\n on. Large slices are sampled uniformly to stay under a token budget.\n- **`classify`** buckets free-text cells into a known set of labels, and\n filters out any prediction not in your `classes` list so the output is a\n clean enum.\n- **`export`** turns \"export EU orders from Q2 as a grouped PDF by\n country\" into a `{ format, filters, sort, groupBy }` plan and hands it to\n the exporter - self-contained, so the download is correct regardless of\n the grid's current view.\n- **`findAnomalies`** scans a slice for outliers and inconsistent values,\n each tagged `low | medium | high`. Pairs naturally with export: find the\n odd rows, then export just those.\n\nFull API, response shapes, and the license gate are on the\n[AI assistant](./ai.md) page.\n\n## Build an agent that drives the grid\n\nThe imperative `SvGridApi` is a clean tool surface - each method becomes\none function a model can call. Three patterns, in order of how much agency\nyou hand over:\n\n1. **Read-only summary agent** - the model describes the current view\n (`api.getDisplayedRows()`), no tool calling.\n2. **Stateful UI agent** - the model calls `setFilter` / `setSort` /\n `setGroupBy` in response to natural language, bounded by a max-turns loop.\n3. **Autonomous workflow agent** - the grid is one node in a longer chain\n (import -> enrich -> human approval -> export), and the visible table is\n the state a human can audit between steps.\n\n```ts\n// Pattern 2, sketched: each SvGridApi method is one tool the model can call.\nswitch (call.function.name) {\n case 'setFilter': api.setFilter(args.columnId, args); break\n case 'setSort': api.setSort(args.columnId, args.direction); break\n case 'setGroupBy': api.setGroupBy(args.columnIds); break\n case 'clearAllFilters': api.clearAllFilters(); break\n}\n```\n\nFull worked code, the sandboxing rules (whitelist tools, validate every\nargument against the shipped JSON Schemas, bound the loop), and the common\nfailure modes are on the [Agents](./agents.md) page.\n\n## MCP server: let your coding agent write the grid\n\nThe [MCP server](./mcp-server.md) exposes SvGrid to AI clients (Claude\nDesktop, Cursor, Zed, Continue, custom agents) over the Model Context\nProtocol. It grounds the model in the schemas the library actually ships,\nso your assistant retrieves version-pinned facts instead of hallucinating\nan API from its training cutoff. No API key, all local.\n\n```json\n{\n \"mcpServers\": {\n \"sv-grid\": { \"command\": \"npx\", \"args\": [\"-y\", \"@sv-grid/mcp-server\"] }\n }\n}\n```\n\nIt registers callable tools - `searchDocs`, `getDocPage`, `scaffoldColumns`\n(sample row -> `ColumnDef[]`), `validateColumns`, `previewExport`,\n`listDemos` - plus read-only resources (the docs manifest and JSON Schemas)\nand pre-built prompts (`/svgrid:scaffold-grid`, `/svgrid:refactor-to-pivot`,\n`/svgrid:wire-server-side`).\n\nFor **Studio** (turning a database or schema into a CRUD data-app), the\nseparate [`@svgrid/mcp`](../enterprise/studio/ai-generation.md) server adds\n`introspect_source` and `scaffold_entity`. The generated screen is run\nthrough the Svelte compiler before it comes back, and each file carries\n`svgrid:managed` markers so a re-generation updates the managed regions and\nleaves your hand-written code untouched.\n\n## Ground any model, no MCP required\n\nIf you are not on an MCP client, four static artefacts ship with the docs\nso any model can ground itself in current facts:\n\n| File | Use for |\n| --- | --- |\n| [`/llms.txt`](/llms.txt) | First-pass context: the topic map with one-line summaries |\n| [`/llms-full.txt`](/llms-full.txt) | Deep grounding: every doc page concatenated |\n| [`/docs.json`](/docs.json) | Programmatic crawling: section tree + per-page metadata |\n| [`/schemas/index.json`](/schemas/index.json) | Validation: machine-checkable `ColumnDef`, `<SvGrid>` props, export options |\n\nUpload `llms-full.txt` into a custom GPT or Claude project, drop a rules\nblock into `.cursorrules`, or fetch the topic map into your own agent's\nsystem prompt at boot. All four are regenerated on every commit and served\nfrom the docs origin. Full recipes are on the\n[LLM grounding](./llm-grounding.md) page.\n\n## Best practices\n\n**Prompting the in-grid helpers.** These are handled for you - the grid\nalready embeds the column schema and samples rows before each call - but\nif you customise the prompt on your provider side:\n\n- Keep the live column set in front of the model every turn; it is the\n single biggest defence against invented field names.\n- Include a few sample rows so the model learns value shapes (region codes,\n date formats, enum spellings).\n- State the column ids exactly, including case - ids are case-sensitive\n (`snake_case` vs `PascalCase` matters).\n\n**Previewing before committing.** `filter` and `export` default to\nreturning a plan without touching the grid. Show the `rationale`, let the\nuser confirm, then apply. This is the pattern that makes NL features feel\ntrustworthy rather than magic-that-sometimes-breaks.\n\n**Cost routing.** The `task` tag (`filter | smart-fill | summarize |\nclassify`) and the `maxOutputTokens` hint let you route a cheap model for\nfilters and a stronger one for summaries from inside your one adapter.\n\n**Data handling.** The grid makes no network calls of its own - the AI\nhelpers send exactly the prompt you construct to the adapter you configure.\nRoute through your own `/api/ai` proxy if you need to redact, log, or keep\ndata within a boundary before it reaches a provider.\n\n## Examples\n\n- **[Demo 51 - AI assistant](../../examples/src/demos/51-ai-assistant.svelte)** -\n all six helpers wired to the mock provider, per-cell accept with\n confidence pills.\n- **[AI Smart Paste](./ai-smart-paste.md)** - parse vCard / Markdown /\n signature blocks / CSV into typed rows, with email-typo correction and\n phone normalisation.\n\n## API reference\n\n| Symbol | Package | What it is |\n| --- | --- | --- |\n| `setAIProvider(p)` | `@svgrid/enterprise` | Register the model adapter every AI call routes through. `null` clears it. |\n| `mockAIProvider` | `@svgrid/enterprise` | Deterministic canned provider for demos and tests. |\n| `type AIProvider` | `@svgrid/enterprise` | `(req: AIRequest) => Promise<string>` - the one function you implement. |\n| `api.ai.filter` / `smartFill` / `summarize` / `classify` / `export` / `findAnomalies` | `@svgrid/enterprise` | The in-grid helpers, added by `installEnterprise(api)`. |\n| `scaffoldColumns`, `validateColumns`, `previewExport`, ... | `@sv-grid/mcp-server` | Build-time MCP tools your coding agent calls. |\n| `introspect_source`, `scaffold_entity` | `@svgrid/mcp` | Studio generation tools (schema -> CRUD screen). |\n\nAuto-generated per-symbol reference: [`@svgrid/enterprise` · `ai.ts`](../reference/auto/svgrid-enterprise-ai.md).\n\n## See also\n\n- [AI assistant](./ai.md) - the in-grid helpers in full, with response shapes\n- [Agents](./agents.md) - build an agent that drives the live grid\n- [Agent Skill](./skill.md) - always-on, project-aware context and house style for coding assistants\n- [MCP server](./mcp-server.md) - turnkey integration for Claude Desktop / Cursor / Zed\n- [LLM grounding](./llm-grounding.md) - the static files any model reads\n- [AI generation - Studio](../enterprise/studio/ai-generation.md) - scaffold CRUD data-apps from a schema\n\n## Frequently asked questions\n\n### What AI features does SvGrid have?\n\nTwo kinds. At runtime, `@svgrid/grid` ships six model-agnostic helpers\nfree - natural-language filter, smart fill, summarise, classify, export,\nand anomaly detection. At build time, an MCP\nserver plus grounding files let your coding agent write correct SvGrid code\nand scaffold CRUD screens.\n\n### Which model does SvGrid use?\n\nNone by default - it is bring-your-own. You register one adapter for\nOpenAI, Anthropic Claude, a local model, or a server proxy, and the grid\nroutes every AI call through it. A deterministic mock provider ships so you\ncan evaluate the whole flow without a key.\n\n### Is my grid data sent to a model provider?\n\nOnly if you wire one up and invoke a helper. SvGrid itself makes no network\ncalls; the AI helpers send exactly the prompt you construct to the adapter\nyou configure, so you decide what leaves the browser and can proxy it\nthrough your own backend first.\n\n### Do I need the MCP server to use the AI features?\n\nNo. The in-grid helpers and the grounding files work without it. The MCP\nserver is the turnkey path for desktop AI clients; for a custom in-app\nagent you call `SvGridApi` directly.\n"
3053
+ "markdown": "# AI Toolkit\r\n\r\nEverything SvGrid ships for building with language models, in one place.\r\nThe toolkit spans two axes: **AI inside your running app** (helpers your\r\nusers invoke - natural-language filter, smart fill, summarise, classify)\r\nand **AI inside your editor** (the MCP server + grounding files that make\r\nClaude, Cursor, and friends write correct SvGrid code).\r\n\r\nNothing here bundles a model. SvGrid is **model-agnostic and\r\nbring-your-own-key**: you register one adapter and keep full control of\r\nmodel choice, routing, and what data leaves the browser.\r\n\r\n<div data-docs-demo=\"51-ai-assistant\" data-height=\"560\"></div>\r\n\r\n## The two surfaces\r\n\r\n| | AI in your app (runtime) | AI in your editor (build time) |\r\n| --- | --- | --- |\r\n| **Who invokes it** | your end users | you and your coding agent |\r\n| **What it does** | filter / fill / summarise / classify / export the live grid | scaffold columns, generate CRUD screens, answer API questions |\r\n| **Package** | `@svgrid/enterprise` (`api.ai.*`) | `@svgrid/mcp`, grounding files |\r\n| **Needs a model key** | yes - the one you register | no - your agent brings its own |\r\n| **Deep dive** | [AI assistant](./ai.md) | [MCP server](./mcp-server.md) · [LLM grounding](./llm-grounding.md) |\r\n\r\nMost teams use both: the MCP server to write the grid, the in-grid\r\nhelpers to power features inside it.\r\n\r\n## How it works\r\n\r\nThe grid never calls a model directly. Every runtime AI call routes\r\nthrough a single async **provider** you register once at app boot:\r\n\r\n```ts\r\nimport { setAIProvider, type AIProvider } from '@svgrid/grid'\r\n\r\nconst provider: AIProvider = async ({ prompt, responseFormat, signal, task }) => {\r\n const r = await fetch('/api/ai', {\r\n method: 'POST',\r\n headers: { 'content-type': 'application/json' },\r\n body: JSON.stringify({ prompt, responseFormat, task }),\r\n signal,\r\n })\r\n if (!r.ok) throw new Error(`AI provider returned ${r.status}`)\r\n return r.text()\r\n}\r\n\r\nsetAIProvider(provider)\r\n```\r\n\r\nThree design choices keep bad model output from becoming a silent wrong answer:\r\n\r\n- **Structured JSON, validated.** Helpers request `responseFormat: 'json'`\r\n and the grid `JSON.parse`s the reply. It strips a single markdown code\r\n fence automatically, so a model that wraps output in ` ```json ... ``` `\r\n still parses. On malformed output you get a typed error, not a silent\r\n wrong result.\r\n- **The prompt is grounded in your columns.** Before each call the grid\r\n embeds the live column schema (names, types, sampled values) into the\r\n prompt, so the model picks real field names instead of inventing them.\r\n- **A hallucination guard on the way back.** If a model still returns a\r\n column that does not exist, the clause is dropped rather than passed to\r\n `setFilter` - you lose a clause, never crash the page.\r\n\r\nThe provider shape is deliberately tiny (one async call, `text` or `json`\r\nresponse) so the same adapter drives an OpenAI `chat.completions` call, an\r\nAnthropic `messages` call, a self-hosted endpoint, or a server-side proxy.\r\nNo model client is ever bundled into your grid.\r\n\r\n> **Just evaluating?** The package ships a deterministic `mockAIProvider`\r\n> that returns plausible canned shapes per task. Wire it in with\r\n> `setAIProvider(mockAIProvider)` and every helper works end to end with\r\n> no key. The demo above runs on it.\r\n\r\n## In-grid helpers\r\n\r\n`installEnterprise(api)` - the same call you use for export and print -\r\naugments your `SvGridApi` with an `ai` namespace. Six helpers, all\r\nmodel-agnostic:\r\n\r\n```ts\r\napi.ai.filter(query, opts?) // NL sentence -> filter + sort plan\r\napi.ai.smartFill(opts) // 1-2 examples -> proposed column values\r\napi.ai.summarize(opts) // row/selection/group/all -> text + bullets\r\napi.ai.classify(opts) // free-text cells -> a clean enum label\r\napi.ai.export(query, opts?) // NL sentence -> filter + group + format, then export\r\napi.ai.findAnomalies(opts?) // scan a slice -> outliers + severity\r\n```\r\n\r\n### Natural-language filter\r\n\r\nThe highest-leverage feature: replace a dozen per-column filter operators\r\nwith one search box.\r\n\r\n```ts\r\nconst plan = await api.ai.filter('accounts losing momentum in EMEA, by NPS')\r\n// {\r\n// filters: [\r\n// { field: 'region', operator: 'equals', value: 'EMEA' },\r\n// { field: 'nps', operator: 'lessThan', value: '30' },\r\n// ],\r\n// sort: [{ field: 'nps', desc: false }],\r\n// rationale: 'EMEA region, low NPS, sorted ascending.',\r\n// }\r\n```\r\n\r\nBy default it **returns the plan without applying it**, so you can show a\r\n\"here is what I would do, accept?\" preview and surface the `rationale`.\r\nPass `{ apply: true }` to commit straight to the grid.\r\n\r\n### Smart fill\r\n\r\nThe killer feature for spreadsheet-style entry: type one or two examples\r\nin a column, let the model propose the rest.\r\n\r\n```ts\r\nconst result = await api.ai.smartFill({\r\n field: 'tier',\r\n examples: [\r\n { input: { company: 'Northwind' }, output: 'enterprise' },\r\n { input: { company: 'Helios' }, output: 'growth' },\r\n ],\r\n})\r\n// result.predictions: [{ rowIndex, value, confidence }, ...]\r\n```\r\n\r\nYou choose what to do with the predictions - accept-all, accept-per-cell\r\nwith a confidence pill, or write them onto the row for review.\r\n\r\n### Summarise, classify, export, anomalies\r\n\r\n- **`summarize`** drops a slice (row / selection / group / all) into the\r\n model and returns a paragraph, bullets, and the fields the story leans\r\n on. Large slices are sampled uniformly to stay under a token budget.\r\n- **`classify`** buckets free-text cells into a known set of labels, and\r\n filters out any prediction not in your `classes` list so the output is a\r\n clean enum.\r\n- **`export`** turns \"export EU orders from Q2 as a grouped PDF by\r\n country\" into a `{ format, filters, sort, groupBy }` plan and hands it to\r\n the exporter - self-contained, so the download is correct regardless of\r\n the grid's current view.\r\n- **`findAnomalies`** scans a slice for outliers and inconsistent values,\r\n each tagged `low | medium | high`. Pairs naturally with export: find the\r\n odd rows, then export just those.\r\n\r\nFull API, response shapes, and the license gate are on the\r\n[AI assistant](./ai.md) page.\r\n\r\n## Build an agent that drives the grid\r\n\r\nThe imperative `SvGridApi` is a clean tool surface - each method becomes\r\none function a model can call. Three patterns, in order of how much agency\r\nyou hand over:\r\n\r\n1. **Read-only summary agent** - the model describes the current view\r\n (`api.getDisplayedRows()`), no tool calling.\r\n2. **Stateful UI agent** - the model calls `setFilter` / `setSort` /\r\n `setGroupBy` in response to natural language, bounded by a max-turns loop.\r\n3. **Autonomous workflow agent** - the grid is one node in a longer chain\r\n (import -> enrich -> human approval -> export), and the visible table is\r\n the state a human can audit between steps.\r\n\r\n```ts\r\n// Pattern 2, sketched: each SvGridApi method is one tool the model can call.\r\nswitch (call.function.name) {\r\n case 'setFilter': api.setFilter(args.columnId, args); break\r\n case 'setSort': api.setSort(args.columnId, args.direction); break\r\n case 'setGroupBy': api.setGroupBy(args.columnIds); break\r\n case 'clearAllFilters': api.clearAllFilters(); break\r\n}\r\n```\r\n\r\nFull worked code, the sandboxing rules (whitelist tools, validate every\r\nargument against the shipped JSON Schemas, bound the loop), and the common\r\nfailure modes are on the [Agents](./agents.md) page.\r\n\r\n## MCP server: let your coding agent write the grid\r\n\r\nThe [MCP server](./mcp-server.md) exposes SvGrid to AI clients (Claude\r\nDesktop, Cursor, Zed, Continue, custom agents) over the Model Context\r\nProtocol. It grounds the model in the schemas the library actually ships,\r\nso your assistant retrieves version-pinned facts instead of hallucinating\r\nan API from its training cutoff. No API key, all local.\r\n\r\n```json\r\n{\r\n \"mcpServers\": {\r\n \"svgrid\": { \"command\": \"npx\", \"args\": [\"-y\", \"@svgrid/mcp\"] }\r\n }\r\n}\r\n```\r\n\r\nIt registers callable tools - `list_examples`, `get_example_source`,\r\n`list_docs`, `get_doc`, `search_docs`, and `get_api_reference` - so the\r\nagent reads real demo source and current docs instead of guessing.\r\n\r\nFor **Studio** (turning a database or schema into a CRUD data-app), the\r\n[same server](../enterprise/studio/ai-generation.md) adds\r\n`introspect_source`, `scaffold_entity`, and 27 `studio_*` project-model\r\ntools. The generated screen is run\r\nthrough the Svelte compiler before it comes back, and each file carries\r\n`svgrid:managed` markers so a re-generation updates the managed regions and\r\nleaves your hand-written code untouched.\r\n\r\n## Ground any model, no MCP required\r\n\r\nIf you are not on an MCP client, four static artefacts ship with the docs\r\nso any model can ground itself in current facts:\r\n\r\n| File | Use for |\r\n| --- | --- |\r\n| [`/llms.txt`](/llms.txt) | First-pass context: the topic map with one-line summaries |\r\n| [`/llms-full.txt`](/llms-full.txt) | Deep grounding: every doc page concatenated |\r\n| [`/docs.json`](/docs.json) | Programmatic crawling: section tree + per-page metadata |\r\n| [`/schemas/index.json`](/schemas/index.json) | Validation: machine-checkable `ColumnDef`, `<SvGrid>` props, export options |\r\n\r\nUpload `llms-full.txt` into a custom GPT or Claude project, drop a rules\r\nblock into `.cursorrules`, or fetch the topic map into your own agent's\r\nsystem prompt at boot. All four are regenerated on every commit and served\r\nfrom the docs origin. Full recipes are on the\r\n[LLM grounding](./llm-grounding.md) page.\r\n\r\n## Best practices\r\n\r\n**Prompting the in-grid helpers.** These are handled for you - the grid\r\nalready embeds the column schema and samples rows before each call - but\r\nif you customise the prompt on your provider side:\r\n\r\n- Keep the live column set in front of the model every turn; it is the\r\n single biggest defence against invented field names.\r\n- Include a few sample rows so the model learns value shapes (region codes,\r\n date formats, enum spellings).\r\n- State the column ids exactly, including case - ids are case-sensitive\r\n (`snake_case` vs `PascalCase` matters).\r\n\r\n**Previewing before committing.** `filter` and `export` default to\r\nreturning a plan without touching the grid. Show the `rationale`, let the\r\nuser confirm, then apply. This is the pattern that makes NL features feel\r\ntrustworthy rather than magic-that-sometimes-breaks.\r\n\r\n**Cost routing.** The `task` tag (`filter | smart-fill | summarize |\r\nclassify`) and the `maxOutputTokens` hint let you route a cheap model for\r\nfilters and a stronger one for summaries from inside your one adapter.\r\n\r\n**Data handling.** The grid makes no network calls of its own - the AI\r\nhelpers send exactly the prompt you construct to the adapter you configure.\r\nRoute through your own `/api/ai` proxy if you need to redact, log, or keep\r\ndata within a boundary before it reaches a provider.\r\n\r\n## Examples\r\n\r\n- **[Demo 51 - AI assistant](../../examples/src/demos/51-ai-assistant.svelte)** -\r\n all six helpers wired to the mock provider, per-cell accept with\r\n confidence pills.\r\n- **[AI Smart Paste](./ai-smart-paste.md)** - parse vCard / Markdown /\r\n signature blocks / CSV into typed rows, with email-typo correction and\r\n phone normalisation.\r\n\r\n## API reference\r\n\r\n| Symbol | Package | What it is |\r\n| --- | --- | --- |\r\n| `setAIProvider(p)` | `@svgrid/enterprise` | Register the model adapter every AI call routes through. `null` clears it. |\r\n| `mockAIProvider` | `@svgrid/enterprise` | Deterministic canned provider for demos and tests. |\r\n| `type AIProvider` | `@svgrid/enterprise` | `(req: AIRequest) => Promise<string>` - the one function you implement. |\r\n| `api.ai.filter` / `smartFill` / `summarize` / `classify` / `export` / `findAnomalies` | `@svgrid/enterprise` | The in-grid helpers, added by `installEnterprise(api)`. |\r\n| `search_docs`, `get_doc`, `list_examples`, `get_example_source`, `get_api_reference`, `list_docs` | `@svgrid/mcp` | Build-time MCP tools your coding agent calls. |\r\n| `introspect_source`, `scaffold_entity`, `studio_*` | `@svgrid/mcp` | Studio generation tools (schema -> CRUD screen, project model). |\r\n\r\nAuto-generated per-symbol reference: [`@svgrid/enterprise` · `ai.ts`](../reference/auto/svgrid-enterprise-ai.md).\r\n\r\n## See also\r\n\r\n- [AI assistant](./ai.md) - the in-grid helpers in full, with response shapes\r\n- [Agents](./agents.md) - build an agent that drives the live grid\r\n- [Agent Skill](./skill.md) - always-on, project-aware context and house style for coding assistants\r\n- [MCP server](./mcp-server.md) - turnkey integration for Claude Desktop / Cursor / Zed\r\n- [LLM grounding](./llm-grounding.md) - the static files any model reads\r\n- [AI generation - Studio](../enterprise/studio/ai-generation.md) - scaffold CRUD data-apps from a schema\r\n\r\n## Frequently asked questions\r\n\r\n### What AI features does SvGrid have?\r\n\r\nTwo kinds. At runtime, `@svgrid/grid` ships six model-agnostic helpers\r\nfree - natural-language filter, smart fill, summarise, classify, export,\r\nand anomaly detection. At build time, an MCP\r\nserver plus grounding files let your coding agent write correct SvGrid code\r\nand scaffold CRUD screens.\r\n\r\n### Which model does SvGrid use?\r\n\r\nNone by default - it is bring-your-own. You register one adapter for\r\nOpenAI, Anthropic Claude, a local model, or a server proxy, and the grid\r\nroutes every AI call through it. A deterministic mock provider ships so you\r\ncan evaluate the whole flow without a key.\r\n\r\n### Is my grid data sent to a model provider?\r\n\r\nOnly if you wire one up and invoke a helper. SvGrid itself makes no network\r\ncalls; the AI helpers send exactly the prompt you construct to the adapter\r\nyou configure, so you decide what leaves the browser and can proxy it\r\nthrough your own backend first.\r\n\r\n### Do I need the MCP server to use the AI features?\r\n\r\nNo. The in-grid helpers and the grounding files work without it. The MCP\r\nserver is the turnkey path for desktop AI clients; for a custom in-app\r\nagent you call `SvGridApi` directly.\r\n"
3042
3054
  },
3043
3055
  {
3044
3056
  "slug": "help/ai",
@@ -3062,13 +3074,13 @@ export const docs = [
3062
3074
  "slug": "help/architecture",
3063
3075
  "path": "docs/help/architecture.md",
3064
3076
  "title": "Architecture overview",
3065
- "markdown": "# Architecture overview\r\n\r\nA one-page mental model that should let you reason about every other\r\ntopic in the docs. SvGrid is a strict three-layer system - if you know\r\nwhich layer a piece of code lives in, you know what it can and cannot\r\ndo.\r\n\r\n![Three layers: your data feeds the headless createSvGrid engine, which the <SvGrid> renderer draws into a table.](/docs-media/grid-architecture.svg)\r\n\r\n## The three layers\r\n\r\n```\r\n┌─────────────────────────────────────────────────────────────┐\r\n│ Layer 3 │ <SvGrid> render component (Svelte 5) │\r\n│ │ - DOM, scroll, virtualization, editor popovers │\r\n│ │ - keyboard handlers, pointer events │\r\n│ │ - sticks the headless engine to a viewport │\r\n├───────────┼─────────────────────────────────────────────────┤\r\n│ Layer 2 │ Headless engine (createSvGrid) │\r\n│ │ - column model + row model pipeline │\r\n│ │ - sort, filter, group, paginate, expand │\r\n│ │ - aggregators, accessors, comparators │\r\n│ │ - 100% pure functions, no DOM │\r\n├───────────┼─────────────────────────────────────────────────┤\r\n│ Layer 1 │ Your data + your column definitions │\r\n│ │ - the only thing YOU author │\r\n│ │ - plain TypeScript: arrays, objects, types │\r\n└───────────┴─────────────────────────────────────────────────┘\r\n```\r\n\r\nLayer 1 is yours. Layer 2 is `@svgrid/grid` minus the renderer.\r\nLayer 3 is the `<SvGrid>` component everyone uses by default.\r\n\r\n**You can use Layer 2 without Layer 3.** That's the headless promise:\r\nif you want to render the grid yourself - in Tailwind cards, a print\r\nPDF template, a custom virtualization layer - import `createSvGrid`\r\nand read the state directly.\r\n\r\n## Data flow on every render\r\n\r\n```\r\nraw data ──► engine pipeline ──► visible rows ──► renderer\r\n (you) (Layer 2) (Layer 2 out) (Layer 3)\r\n │\r\n ▼\r\n ┌────────────────────────────┐\r\n │ 1. coreRowModel │ shape data into Row objects\r\n │ 2. filteredRowModel │ apply column + global filters\r\n │ 3. sortedRowModel │ apply sort spec\r\n │ 4. groupedRowModel │ apply groupBy + aggregators\r\n │ 5. expandedRowModel │ flatten expanded groups\r\n │ 6. paginatedRowModel │ slice the visible page\r\n └────────────────────────────┘\r\n```\r\n\r\nEvery \"feature\" you register in `tableFeatures({ ... })` plugs one or\r\nmore row models into this pipeline. Disable a feature and that stage\r\nno-ops. The pipeline runs once per state change, **not per scroll\r\nframe** - virtualization is purely a presentational concern.\r\n\r\n## The two APIs you'll use\r\n\r\n### Declarative (the `<SvGrid>` props)\r\n\r\n99% of consumers stop here. You author `data`, `columns`, and `features`,\r\nthen handle events from props:\r\n\r\n```svelte\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n onCellValueChange={handleChange}\r\n onActiveCellChange={handleFocus}\r\n/>\r\n```\r\n\r\n### Imperative (`SvGridApi`)\r\n\r\nFor toolbars, ribbons, keyboard shortcuts that need to drive the grid,\r\nask for the API via `onApiReady`:\r\n\r\n```svelte\r\n<SvGrid\r\n ...\r\n onApiReady={(api) => {\r\n api.setSort('name', 'asc')\r\n api.setFilter('region', { operator: 'equals', value: 'EMEA' })\r\n }}\r\n/>\r\n```\r\n\r\nSee the [API reference](./api-reference.md) for the full surface.\r\n\r\n## State ownership\r\n\r\nTwo questions decide where each piece of state lives:\r\n\r\n| Question | Lives in |\r\n| ------------------------------------------ | ------------------------- |\r\n| Does it change row content or cell values? | **Your component** (`$state`) - hand the new array down to `data`. |\r\n| Is it a column/grid setting (sort, filter, group, page)? | **The engine** owns it. Use `api.setSort(...)` etc., or pass an initial state. |\r\n| Is it a visual concern (column widths, hover)? | **The renderer** owns it. The grid manages this internally. |\r\n\r\nThis split is deliberate: the engine is dataless, so it can't \"lose\"\r\nyour rows. Your component is rendererless, so it can't accidentally\r\nmutate DOM nodes during a sort.\r\n\r\n## Where each topic page sits\r\n\r\n| Topic | Layer | Why |\r\n| ------------------------------------ | ------ | ------------------------------------------------------------------------- |\r\n| [Column definitions](./columns/column-definitions.md) | 1 | Pure types you author. |\r\n| [Row data](./rows/row-data.md) | 1 | Your input. |\r\n| [Row sorting](./rows/row-sorting.md) | 2 | Engine row-model. |\r\n| [Filtering overview](./filtering/overview.md) | 2 + 3 | Engine for the pipeline; renderer for the popovers + filter row. |\r\n| [Row pagination](./rows/row-pagination.md) | 2 | Engine slice. |\r\n| [Editing](./editing/overview.md) | 3 | DOM editors live in the renderer. |\r\n| [Tree rows](./rows/tree-rows.md) | 1 + 3 | You derive `visibleRows`; the renderer indents + draws chevrons. |\r\n| [Pivot tables](./pivot.md) | 1 + 2 | You build the pivot engine; the renderer uses standard nested headers. |\r\n| [AI assistant](./ai.md) | 2 | Pure helpers; the renderer never sees them. |\r\n| [Export / import](./export.md), [import](./import.md) | 2 + 3 | Helpers + browser-side file IO. |\r\n\r\n## Why this matters for shipping\r\n\r\n- **You can test Layer 2 without a DOM.** Every engine helper is a\r\n pure function. Vitest in node, no jsdom required. See\r\n [Testing your grid](./testing.md).\r\n- **You can swap Layer 3.** If your design system has its own table\r\n primitive, drop `<SvGrid>` and read from `createSvGrid()` directly.\r\n- **Layer 2 is the public API surface.** Imports, exports, and types\r\n are versioned per the [API stability](./api-stability.md) policy.\r\n The renderer's CSS classes are NOT - override them at your peril.\r\n\r\n## Where the layers physically live\r\n\r\n| Layer | Source path | Build output |\r\n| ----- | ------------------------------------------------- | ------------------------------------ |\r\n| 1 | Your app | n/a |\r\n| 2 | `packages/grid/src/core.ts` + row-models | `dist/index.js` (~2 kB gzip) |\r\n| 3 | `packages/grid/src/SvGrid.svelte` | bundled with the engine (~78 kB gzip + 9 kB CSS) |\r\n| | `packages/enterprise/src/{export,print,import,ai}.ts` | `@svgrid/enterprise/dist/*` (lazy-loaded peers) |\r\n\r\n## See also\r\n\r\n- [Why headless?](../why-headless.md) - the design rationale for the\r\n Layer 2 / Layer 3 split.\r\n- [API reference](./api-reference.md) - every export with its layer\r\n noted.\r\n- [Performance benchmarks](./benchmarks.md) - numbers from each layer\r\n in isolation.\r\n\r\n## Frequently asked questions\r\n\r\n### How is SvGrid architected?\r\n\r\nAs three strict layers: a headless core engine (state + row-model pipeline), a\r\nSvelte render component (`<SvGrid>`) that draws the DOM, and your application\r\ncode. Knowing which layer a piece of code lives in tells you what it can and\r\ncannot do.\r\n\r\n### What does \"headless\" mean for SvGrid?\r\n\r\nThe core engine computes sorting, filtering, grouping, and selection state\r\nwithout rendering anything. You can drive your own markup with it, or drop in the\r\nbatteries-included `<SvGrid>` component that renders on top of the same engine.\r\n\r\n### Can I use the engine without the SvGrid component?\r\n\r\nYes. Use `createSvGrid` and the row-model factories directly to build a custom\r\nrendering layer. The render component is optional sugar over the same public\r\nengine API.\r\n"
3077
+ "markdown": "# Architecture overview\r\n\r\nA one-page mental model that should let you reason about every other\r\ntopic in the docs. SvGrid is a strict three-layer system - if you know\r\nwhich layer a piece of code lives in, you know what it can and cannot\r\ndo.\r\n\r\n![Three layers: your data feeds the headless createSvGrid engine, which the <SvGrid> renderer draws into a table.](/docs-media/grid-architecture.svg)\r\n\r\n## The three layers\r\n\r\n```\r\n┌─────────────────────────────────────────────────────────────┐\r\n│ Layer 3 │ <SvGrid> render component (Svelte 5) │\r\n│ │ - DOM, scroll, virtualization, editor popovers │\r\n│ │ - keyboard handlers, pointer events │\r\n│ │ - sticks the headless engine to a viewport │\r\n├───────────┼─────────────────────────────────────────────────┤\r\n│ Layer 2 │ Headless engine (createSvGrid) │\r\n│ │ - column model + row model pipeline │\r\n│ │ - sort, filter, group, paginate, expand │\r\n│ │ - aggregators, accessors, comparators │\r\n│ │ - 100% pure functions, no DOM │\r\n├───────────┼─────────────────────────────────────────────────┤\r\n│ Layer 1 │ Your data + your column definitions │\r\n│ │ - the only thing YOU author │\r\n│ │ - plain TypeScript: arrays, objects, types │\r\n└───────────┴─────────────────────────────────────────────────┘\r\n```\r\n\r\nLayer 1 is yours. Layer 2 is `@svgrid/grid` minus the renderer.\r\nLayer 3 is the `<SvGrid>` component everyone uses by default.\r\n\r\n**You can use Layer 2 without Layer 3.** That's the headless promise:\r\nif you want to render the grid yourself - in Tailwind cards, a print\r\nPDF template, a custom virtualization layer - import `createSvGrid`\r\nand read the state directly.\r\n\r\n## Data flow on every render\r\n\r\n```\r\nraw data ──► engine pipeline ──► visible rows ──► renderer\r\n (you) (Layer 2) (Layer 2 out) (Layer 3)\r\n │\r\n ▼\r\n ┌────────────────────────────┐\r\n │ 1. coreRowModel │ shape data into Row objects\r\n │ 2. filteredRowModel │ apply column + global filters\r\n │ 3. sortedRowModel │ apply sort spec\r\n │ 4. groupedRowModel │ apply groupBy + aggregators\r\n │ 5. expandedRowModel │ flatten expanded groups\r\n │ 6. paginatedRowModel │ slice the visible page\r\n └────────────────────────────┘\r\n```\r\n\r\nEvery \"feature\" you register in `tableFeatures({ ... })` plugs one or\r\nmore row models into this pipeline. Disable a feature and that stage\r\nno-ops. The pipeline runs once per state change, **not per scroll\r\nframe** - virtualization is purely a presentational concern.\r\n\r\n## The two APIs you'll use\r\n\r\n### Declarative (the `<SvGrid>` props)\r\n\r\n99% of consumers stop here. You author `data`, `columns`, and `features`,\r\nthen handle events from props:\r\n\r\n```svelte\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n onCellValueChange={handleChange}\r\n onActiveCellChange={handleFocus}\r\n/>\r\n```\r\n\r\n### Imperative (`SvGridApi`)\r\n\r\nFor toolbars, ribbons, keyboard shortcuts that need to drive the grid,\r\nask for the API via `onApiReady`:\r\n\r\n```svelte\r\n<SvGrid\r\n ...\r\n onApiReady={(api) => {\r\n api.setSort('name', 'asc')\r\n api.setFilter('region', { operator: 'equals', value: 'EMEA' })\r\n }}\r\n/>\r\n```\r\n\r\nSee the [API reference](./api-reference.md) for the full surface.\r\n\r\n## State ownership\r\n\r\nTwo questions decide where each piece of state lives:\r\n\r\n| Question | Lives in |\r\n| ------------------------------------------ | ------------------------- |\r\n| Does it change row content or cell values? | **Your component** (`$state`) - hand the new array down to `data`. |\r\n| Is it a column/grid setting (sort, filter, group, page)? | **The engine** owns it. Use `api.setSort(...)` etc., or pass an initial state. |\r\n| Is it a visual concern (column widths, hover)? | **The renderer** owns it. The grid manages this internally. |\r\n\r\nThis split is deliberate: the engine is dataless, so it can't \"lose\"\r\nyour rows. Your component is rendererless, so it can't accidentally\r\nmutate DOM nodes during a sort.\r\n\r\n## Where each topic page sits\r\n\r\n| Topic | Layer | Why |\r\n| ------------------------------------ | ------ | ------------------------------------------------------------------------- |\r\n| [Column definitions](./columns/column-definitions.md) | 1 | Pure types you author. |\r\n| [Row data](./rows/row-data.md) | 1 | Your input. |\r\n| [Row sorting](./rows/row-sorting.md) | 2 | Engine row-model. |\r\n| [Filtering overview](./filtering/overview.md) | 2 + 3 | Engine for the pipeline; renderer for the popovers + filter row. |\r\n| [Row pagination](./rows/row-pagination.md) | 2 | Engine slice. |\r\n| [Editing](./editing/overview.md) | 3 | DOM editors live in the renderer. |\r\n| [Tree rows](./rows/tree-rows.md) | 1 + 3 | You derive `visibleRows`; the renderer indents + draws chevrons. |\r\n| [Pivot tables](./pivot.md) | 1 + 2 | You build the pivot engine; the renderer uses standard nested headers. |\r\n| [AI assistant](./ai.md) | 2 | Pure helpers; the renderer never sees them. |\r\n| [Export / import](./export.md), [import](./import.md) | 2 + 3 | Helpers + browser-side file IO. |\r\n\r\n## Why this matters for shipping\r\n\r\n- **You can test Layer 2 without a DOM.** Every engine helper is a\r\n pure function. Vitest in node, no jsdom required. See\r\n [Testing your grid](./testing.md).\r\n- **You can swap Layer 3.** If your design system has its own table\r\n primitive, drop `<SvGrid>` and read from `createSvGrid()` directly.\r\n- **Layer 2 is the public API surface.** Imports, exports, and types\r\n are versioned per the [API stability](./api-stability.md) policy.\r\n The renderer's CSS classes are NOT - override them at your peril.\r\n\r\n## Where the layers physically live\r\n\r\n| Layer | Source path | Build output |\r\n| ----- | ------------------------------------------------- | ------------------------------------ |\r\n| 1 | Your app | n/a |\r\n| 2 | `packages/grid/src/core.ts` + row-models | `dist/index.js` (~2 kB gzip) |\r\n| 3 | `packages/grid/src/SvGrid.svelte` | bundled with the engine (~77 kB gzip + 9 kB CSS) |\r\n| | `packages/enterprise/src/{export,print,import,ai}.ts` | `@svgrid/enterprise/dist/*` (lazy-loaded peers) |\r\n\r\n## See also\r\n\r\n- [Why headless?](../why-headless.md) - the design rationale for the\r\n Layer 2 / Layer 3 split.\r\n- [API reference](./api-reference.md) - every export with its layer\r\n noted.\r\n- [Performance benchmarks](./benchmarks.md) - numbers from each layer\r\n in isolation.\r\n\r\n## Frequently asked questions\r\n\r\n### How is SvGrid architected?\r\n\r\nAs three strict layers: a headless core engine (state + row-model pipeline), a\r\nSvelte render component (`<SvGrid>`) that draws the DOM, and your application\r\ncode. Knowing which layer a piece of code lives in tells you what it can and\r\ncannot do.\r\n\r\n### What does \"headless\" mean for SvGrid?\r\n\r\nThe core engine computes sorting, filtering, grouping, and selection state\r\nwithout rendering anything. You can drive your own markup with it, or drop in the\r\nbatteries-included `<SvGrid>` component that renders on top of the same engine.\r\n\r\n### Can I use the engine without the SvGrid component?\r\n\r\nYes. Use `createSvGrid` and the row-model factories directly to build a custom\r\nrendering layer. The render component is optional sugar over the same public\r\nengine API.\r\n"
3066
3078
  },
3067
3079
  {
3068
3080
  "slug": "help/benchmarks",
3069
3081
  "path": "docs/help/benchmarks.md",
3070
3082
  "title": "Performance benchmarks",
3071
- "markdown": "# Performance benchmarks\r\n\r\nHeadline numbers from the regression suite. Every figure below is from\r\nthe same hardware + browser configuration, re-measured on each release.\r\n\r\nReproduce them yourself rather than taking these on trust: the\r\n[benchmark harness](../recipes/benchmark-harness.md) is a copy-paste\r\n`<SvGrid>` probe that measures time-to-first-paint across any (rows x\r\ncolumns) matrix, and the bundle-size figures come from `pnpm size`\r\n(`packages/grid/scripts/measure-size.mjs`). Numbers from your own\r\nmachine and data shape are the ones worth planning against.\r\n\r\nLive load - 100k rows x 100 columns with row + column virtualization:\r\n\r\n<div data-docs-demo=\"06-large-dataset\" data-height=\"500\"></div>\r\n\r\n## Test rig\r\n\r\n| Component | Spec |\r\n| --------- | ----------------------------------------- |\r\n| CPU | Apple M2 (8-core), 10W TDP |\r\n| RAM | 16 GB LPDDR5 |\r\n| Browser | Chrome 131 (release channel) |\r\n| Display | 1 page worth of cells visible at any time |\r\n| Throttle | None - the regression run isn't throttled, but we also publish a separate \"4x slow-down\" line below for each scenario |\r\n\r\nNumbers are the median of 5 runs after a warm-up pass. We track the\r\n**95th percentile of frame time** during scroll rather than mean FPS -\r\nthe former catches jank that mean averages smooths over.\r\n\r\n## Bundle size\r\n\r\nProduction build, gzipped. The first two rows come from\r\n`node packages/grid/scripts/measure-size.mjs` (Svelte excluded as a peer);\r\nsee the [bundle size reference](../reference/bundle-size.md).\r\n\r\n| Surface | gzip | Notes |\r\n| ----------------------------------- | ------ | -------------------------------------- |\r\n| `@svgrid/grid` (full `<SvGrid>`) | 80 kB | One import covers the entire renderer; + 9 kB CSS |\r\n| Headless engine (`createGrid`) | 2 kB | If you bring your own renderer |\r\n| Lazy chunks (charts, date editors, menus, export) | 64 kB | Loaded on demand, not in the initial bundle |\r\n| `@svgrid/enterprise` core | 8 kB | Export + print + import shells |\r\n| `@svgrid/enterprise` import module only | 6 kB | Imported via `'@svgrid/enterprise/import'` |\r\n| Peer: `jszip` | 35 kB | Loaded on first `xlsx` export *or* import |\r\n| Peer: `pdfmake` + vfs | ~280 kB| Loaded on first `pdf` export only |\r\n\r\nThe AI helpers are no longer in this table: they moved into the free\r\n`@svgrid/grid` and tree-shake out unless you import them.\r\n\r\nTree-shaking is friendly: importing `{ SvGrid, tableFeatures }`\r\nwithout `rowSortingFeature` doesn't pull the sort module.\r\n\r\n## First paint\r\n\r\n100k synthetic rows, 9 columns, default density, no virtualization\r\noverride. Measured from `mount()` to the first row painting:\r\n\r\n| Scenario | Time (ms) |\r\n| --------------------------------- | --------- |\r\n| 10 rows × 9 cols | 4 |\r\n| 1,000 rows × 9 cols | 14 |\r\n| 10,000 rows × 9 cols | 38 |\r\n| 100,000 rows × 9 cols (virtualized) | 82 |\r\n| 100,000 rows × 100 cols (row + col virtualization) | 110 |\r\n\r\nThe slope is sub-linear because virtualization caps the rendered cell\r\ncount regardless of dataset size.\r\n\r\n## Scroll performance\r\n\r\nSustained vertical scroll, 60 px/frame, measured as the 95th\r\npercentile frame time:\r\n\r\n| Scenario | p95 frame | Equivalent FPS |\r\n| ------------------------------------- | --------- | -------------- |\r\n| 100k rows × 9 cols | 8 ms | ~120 fps |\r\n| 100k rows × 100 cols (col virt) | 11 ms | ~90 fps |\r\n| 100k rows × 9 cols, custom cell snippets w/ sparklines | 14 ms | ~70 fps |\r\n| 100k rows × 9 cols, **4x CPU throttle** | 22 ms | ~45 fps |\r\n\r\nHorizontal scroll on a 100-column grid stays under 12 ms p95 because\r\nthe column virtualizer is identical machinery.\r\n\r\n## Sort, filter, group\r\n\r\nIn-memory operations on 100k rows:\r\n\r\n| Operation | Time (ms) |\r\n| ------------------------------------- | --------- |\r\n| Sort 100k rows by one column | 18 |\r\n| Sort 100k rows by 3 columns (multi-sort) | 28 |\r\n| Filter 100k rows (one operator) | 9 |\r\n| Filter 100k rows (5 operators ANDed) | 17 |\r\n| Group 100k rows by 2 columns + 3 aggregators | 36 |\r\n| Pivot 100k facts → 4 row dims × 2 col dims × 3 measures (see demo 52) | 62 |\r\n\r\nThe sort path uses a stable comparator built per-column to keep\r\nallocations down; the filter pipeline short-circuits on the first\r\nfailing predicate.\r\n\r\n## Memory\r\n\r\nHeap snapshot at idle, 100k rows × 9 columns loaded, after a full\r\nscroll pass:\r\n\r\n- ~22 MB heap (the Row objects + the visible-cell pool).\r\n- Virtualization keeps the rendered DOM under ~600 `<td>` nodes\r\n regardless of dataset size.\r\n- No retained references when the grid unmounts - the cleanup path is\r\n exercised by the unmount test in `svgrid.behavior.test.ts`.\r\n\r\n## Server-side / chunked loading\r\n\r\nDemo [33. Server-side infinite scroll](https://svgrid.com/demos/33-server-infinite/) covers the chunked-load path. Numbers from that demo:\r\n\r\n| Scenario | Result |\r\n| ------------------------------------------------ | --------------------------------------- |\r\n| Initial paint, sparse 100k-row dataset | 110 ms to first chunk visible |\r\n| Scroll 50,000 rows in 1.5 s (fast wheel-flick) | 16 chunk requests cancelled mid-flight |\r\n| Sort 100k server-side rows | round-trip dominated by the mock latency (50-140 ms) |\r\n\r\n## AI helpers\r\n\r\nEnd-to-end timings against the bundled `mockAIProvider`:\r\n\r\n| Helper | Median time (ms) |\r\n| --------------- | ---------------- |\r\n| `aiFilter` | 350-750 (mock latency dominated) |\r\n| `aiSmartFill` (50 rows) | 400-900 |\r\n| `aiSummarize` | 350-750 |\r\n| `aiClassify` (20 rows) | 400-750 |\r\n\r\nAgainst a real model the latency is provider-side. The grid's own\r\nprompt-build + result-parse work stays under ~6 ms even for 1000-row\r\nclassify jobs.\r\n\r\n## Import / export\r\n\r\n| Operation | Time |\r\n| ------------------------------------ | ------ |\r\n| Parse CSV, 10k rows × 9 cols | 28 ms |\r\n| Parse xlsx, 10k rows × 9 cols | 140 ms (jszip unzip-dominated) |\r\n| Export CSV, 10k rows × 9 cols | 18 ms |\r\n| Export xlsx, 10k rows × 9 cols | 220 ms |\r\n| Export PDF, 1k rows × 9 cols (pdfmake) | 700 ms |\r\n\r\n## Reproducing locally\r\n\r\n```bash\r\ngit clone https://github.com/sv-grid/sv-grid\r\ncd sv-grid\r\npnpm install\r\npnpm bench # runs the suite, prints the same table\r\npnpm bench --json > my-results.json # for trend tracking\r\n```\r\n\r\nThe bench script also produces a comparison table against the previous\r\nrun if you pass `--baseline=path/to/prev.json`. Regressions over 10%\r\nfail CI on the main branch.\r\n\r\n## What we *don't* claim\r\n\r\n- \"Smoothest grid on the market\" - that depends entirely on what your\r\n cells render. A sparkline + currency formatter in every cell costs\r\n more than a number, and we don't pretend otherwise.\r\n- \"Zero allocations during scroll\" - the virtualizer recycles DOM\r\n nodes but cell snippets still allocate. The numbers above include\r\n real-world snippets (status pills, mini-bars).\r\n- Single-thread performance > 1M rows. For >1M, do the heavy lifting\r\n on the server and feed chunks through the [server-side infinite\r\n scroll pattern](https://svgrid.com/demos/33-server-infinite/).\r\n\r\n## See also\r\n\r\n- [Browser support](./browser-support.md) - the matrix the benchmarks\r\n ran against.\r\n- [Testing and quality](./testing-and-quality.md) - the coverage\r\n thresholds that gate every release.\r\n\r\n## Frequently asked questions\r\n\r\n### How fast is SvGrid?\r\n\r\nIt virtualizes both rows and columns, so only the visible window is in the DOM -\r\na 100,000-row × 100-column grid scrolls smoothly. The numbers on this page come\r\nfrom a checked-in regression suite re-measured on every release, not marketing\r\nestimates.\r\n\r\n### How many rows can SvGrid handle?\r\n\r\nClient-side, 100k+ rows scroll smoothly thanks to virtualization. For millions\r\nof rows, page or chunk from the server (see Server-side data). The DOM only ever\r\nholds the visible window regardless of total row count.\r\n\r\n### How fast is SvGrid, and how big is it?\r\n\r\nIt ships a much smaller bundle (~78 KB gzipped for the full render component,\r\nor ~2 KB for the headless core) and virtualizes by default. Raw scroll\r\nperformance is comparable for typical workloads; the bigger practical win is\r\nbundle size and a Svelte-native runtime with no framework bridge.\r\n"
3083
+ "markdown": "# Performance benchmarks\r\n\r\nHeadline numbers from the regression suite. Every figure below is from\r\nthe same hardware + browser configuration, re-measured on each release.\r\n\r\nReproduce them yourself rather than taking these on trust: the\r\n[benchmark harness](../recipes/benchmark-harness.md) is a copy-paste\r\n`<SvGrid>` probe that measures time-to-first-paint across any (rows x\r\ncolumns) matrix, and the bundle-size figures come from `pnpm size`\r\n(`packages/grid/scripts/measure-size.mjs`). Numbers from your own\r\nmachine and data shape are the ones worth planning against.\r\n\r\nLive load - 100k rows x 100 columns with row + column virtualization:\r\n\r\n<div data-docs-demo=\"06-large-dataset\" data-height=\"500\"></div>\r\n\r\n## Test rig\r\n\r\n| Component | Spec |\r\n| --------- | ----------------------------------------- |\r\n| CPU | Apple M2 (8-core), 10W TDP |\r\n| RAM | 16 GB LPDDR5 |\r\n| Browser | Chrome 131 (release channel) |\r\n| Display | 1 page worth of cells visible at any time |\r\n| Throttle | None - the regression run isn't throttled, but we also publish a separate \"4x slow-down\" line below for each scenario |\r\n\r\nNumbers are the median of 5 runs after a warm-up pass. We track the\r\n**95th percentile of frame time** during scroll rather than mean FPS -\r\nthe former catches jank that mean averages smooths over.\r\n\r\n## Bundle size\r\n\r\nProduction build, gzipped. The first two rows come from\r\n`node packages/grid/scripts/measure-size.mjs` (Svelte excluded as a peer);\r\nsee the [bundle size reference](../reference/bundle-size.md).\r\n\r\n| Surface | gzip | Notes |\r\n| ----------------------------------- | ------ | -------------------------------------- |\r\n| `@svgrid/grid` (full `<SvGrid>`) | 77 kB | One import covers the entire renderer; + 9 kB CSS |\r\n| Headless engine (`createGrid`) | 2 kB | If you bring your own renderer |\r\n| Lazy chunks (charts, date editors, menus, export) | 64 kB | Loaded on demand, not in the initial bundle |\r\n| `@svgrid/enterprise` core | 8 kB | Export + print + import shells |\r\n| `@svgrid/enterprise` import module only | 6 kB | Imported via `'@svgrid/enterprise/import'` |\r\n| Peer: `jszip` | 35 kB | Loaded on first `xlsx` export *or* import |\r\n| Peer: `pdfmake` + vfs | ~280 kB| Loaded on first `pdf` export only |\r\n\r\nThe AI helpers are no longer in this table: they moved into the free\r\n`@svgrid/grid` and tree-shake out unless you import them.\r\n\r\nTree-shaking is friendly: importing `{ SvGrid, tableFeatures }`\r\nwithout `rowSortingFeature` doesn't pull the sort module.\r\n\r\n## First paint\r\n\r\n100k synthetic rows, 9 columns, default density, no virtualization\r\noverride. Measured from `mount()` to the first row painting:\r\n\r\n| Scenario | Time (ms) |\r\n| --------------------------------- | --------- |\r\n| 10 rows × 9 cols | 4 |\r\n| 1,000 rows × 9 cols | 14 |\r\n| 10,000 rows × 9 cols | 38 |\r\n| 100,000 rows × 9 cols (virtualized) | 82 |\r\n| 100,000 rows × 100 cols (row + col virtualization) | 110 |\r\n\r\nThe slope is sub-linear because virtualization caps the rendered cell\r\ncount regardless of dataset size.\r\n\r\n## Scroll performance\r\n\r\nSustained vertical scroll, 60 px/frame, measured as the 95th\r\npercentile frame time:\r\n\r\n| Scenario | p95 frame | Equivalent FPS |\r\n| ------------------------------------- | --------- | -------------- |\r\n| 100k rows × 9 cols | 8 ms | ~120 fps |\r\n| 100k rows × 100 cols (col virt) | 11 ms | ~90 fps |\r\n| 100k rows × 9 cols, custom cell snippets w/ sparklines | 14 ms | ~70 fps |\r\n| 100k rows × 9 cols, **4x CPU throttle** | 22 ms | ~45 fps |\r\n\r\nHorizontal scroll on a 100-column grid stays under 12 ms p95 because\r\nthe column virtualizer is identical machinery.\r\n\r\n## Sort, filter, group\r\n\r\nIn-memory operations on 100k rows:\r\n\r\n| Operation | Time (ms) |\r\n| ------------------------------------- | --------- |\r\n| Sort 100k rows by one column | 18 |\r\n| Sort 100k rows by 3 columns (multi-sort) | 28 |\r\n| Filter 100k rows (one operator) | 9 |\r\n| Filter 100k rows (5 operators ANDed) | 17 |\r\n| Group 100k rows by 2 columns + 3 aggregators | 36 |\r\n| Pivot 100k facts → 4 row dims × 2 col dims × 3 measures (see demo 52) | 62 |\r\n\r\nThe sort path uses a stable comparator built per-column to keep\r\nallocations down; the filter pipeline short-circuits on the first\r\nfailing predicate.\r\n\r\n## Memory\r\n\r\nHeap snapshot at idle, 100k rows × 9 columns loaded, after a full\r\nscroll pass:\r\n\r\n- ~22 MB heap (the Row objects + the visible-cell pool).\r\n- Virtualization keeps the rendered DOM under ~600 `<td>` nodes\r\n regardless of dataset size.\r\n- No retained references when the grid unmounts - the cleanup path is\r\n exercised by the unmount test in `svgrid.behavior.test.ts`.\r\n\r\n## Server-side / chunked loading\r\n\r\nDemo [33. Server-side infinite scroll](https://svgrid.com/demos/33-server-infinite/) covers the chunked-load path. Numbers from that demo:\r\n\r\n| Scenario | Result |\r\n| ------------------------------------------------ | --------------------------------------- |\r\n| Initial paint, sparse 100k-row dataset | 110 ms to first chunk visible |\r\n| Scroll 50,000 rows in 1.5 s (fast wheel-flick) | 16 chunk requests cancelled mid-flight |\r\n| Sort 100k server-side rows | round-trip dominated by the mock latency (50-140 ms) |\r\n\r\n## AI helpers\r\n\r\nEnd-to-end timings against the bundled `mockAIProvider`:\r\n\r\n| Helper | Median time (ms) |\r\n| --------------- | ---------------- |\r\n| `aiFilter` | 350-750 (mock latency dominated) |\r\n| `aiSmartFill` (50 rows) | 400-900 |\r\n| `aiSummarize` | 350-750 |\r\n| `aiClassify` (20 rows) | 400-750 |\r\n\r\nAgainst a real model the latency is provider-side. The grid's own\r\nprompt-build + result-parse work stays under ~6 ms even for 1000-row\r\nclassify jobs.\r\n\r\n## Import / export\r\n\r\n| Operation | Time |\r\n| ------------------------------------ | ------ |\r\n| Parse CSV, 10k rows × 9 cols | 28 ms |\r\n| Parse xlsx, 10k rows × 9 cols | 140 ms (jszip unzip-dominated) |\r\n| Export CSV, 10k rows × 9 cols | 18 ms |\r\n| Export xlsx, 10k rows × 9 cols | 220 ms |\r\n| Export PDF, 1k rows × 9 cols (pdfmake) | 700 ms |\r\n\r\n## Reproducing locally\r\n\r\n```bash\r\ngit clone https://github.com/sv-grid/sv-grid\r\ncd sv-grid\r\npnpm install\r\npnpm bench # runs the suite, prints the same table\r\npnpm bench --json > my-results.json # for trend tracking\r\n```\r\n\r\nThe bench script also produces a comparison table against the previous\r\nrun if you pass `--baseline=path/to/prev.json`. Regressions over 10%\r\nfail CI on the main branch.\r\n\r\n## What we *don't* claim\r\n\r\n- \"Smoothest grid on the market\" - that depends entirely on what your\r\n cells render. A sparkline + currency formatter in every cell costs\r\n more than a number, and we don't pretend otherwise.\r\n- \"Zero allocations during scroll\" - the virtualizer recycles DOM\r\n nodes but cell snippets still allocate. The numbers above include\r\n real-world snippets (status pills, mini-bars).\r\n- Single-thread performance > 1M rows. For >1M, do the heavy lifting\r\n on the server and feed chunks through the [server-side infinite\r\n scroll pattern](https://svgrid.com/demos/33-server-infinite/).\r\n\r\n## See also\r\n\r\n- [Browser support](./browser-support.md) - the matrix the benchmarks\r\n ran against.\r\n- [Testing and quality](./testing-and-quality.md) - the coverage\r\n thresholds that gate every release.\r\n\r\n## Frequently asked questions\r\n\r\n### How fast is SvGrid?\r\n\r\nIt virtualizes both rows and columns, so only the visible window is in the DOM -\r\na 100,000-row × 100-column grid scrolls smoothly. The numbers on this page come\r\nfrom a checked-in regression suite re-measured on every release, not marketing\r\nestimates.\r\n\r\n### How many rows can SvGrid handle?\r\n\r\nClient-side, 100k+ rows scroll smoothly thanks to virtualization. For millions\r\nof rows, page or chunk from the server (see Server-side data). The DOM only ever\r\nholds the visible window regardless of total row count.\r\n\r\n### How fast is SvGrid, and how big is it?\r\n\r\nIt ships a much smaller bundle (~77 KB gzipped for the full render component,\r\nor ~2 KB for the headless core) and virtualizes by default. Raw scroll\r\nperformance is comparable for typical workloads; the bigger practical win is\r\nbundle size and a Svelte-native runtime with no framework bridge.\r\n"
3072
3084
  },
3073
3085
  {
3074
3086
  "slug": "help/browser-support",
@@ -3212,7 +3224,7 @@ export const docs = [
3212
3224
  "slug": "help/columns/column-sizing",
3213
3225
  "path": "docs/help/columns/column-sizing.md",
3214
3226
  "title": "Column sizing",
3215
- "markdown": "# Column sizing\n\nEach column has a pixel width. The default for all columns is the grid's\n`columnWidth` prop (default ~140 px); each column can override via its\n`width` field.\n<div data-docs-demo=\"63-column-layout-api\" data-height=\"540\"></div>\n\n```ts\nconst columns: ColumnDef<{}, Person>[] = [\n { field: 'firstName', header: 'First name', width: 150 },\n { field: 'department', header: 'Department', width: 180 },\n { field: 'salary', header: 'Salary', width: 120 },\n]\n```\n\n```svelte\n<SvGrid {columns} {data} features={{}} columnWidth={140} />\n```\n\n## User resizing\n\nEvery column has a resize handle on its right edge. Drag to widen /\nnarrow; the minimum is 40 px. Resizes are stored per column id inside\nthe grid component.\n\nThere is no opt-out today - if you do not want users to resize a column,\noverlay your own pointer-blocking element on the header or wrap in CSS:\n\n```css\ntable[role='grid'] th[data-col-id=\"firstName\"] [data-resize-handle] {\n pointer-events: none;\n}\n```\n\n## Programmatic resizing + persistence\n\nThe imperative API exposes `setColumnWidth` and `getColumnWidths`:\n\n```svelte\n<script lang=\"ts\">\n import type { SvGridApi } from '@svgrid/grid'\n let api = $state<SvGridApi<typeof features, Person> | null>(null)\n\n function save() {\n if (!api) return\n localStorage.setItem('widths', JSON.stringify(api.getColumnWidths()))\n }\n function restore() {\n if (!api) return\n const saved: Record<string, number> = JSON.parse(localStorage.getItem('widths') ?? '{}')\n for (const [id, w] of Object.entries(saved)) api.setColumnWidth(id, w)\n }\n</script>\n\n<SvGrid {data} {columns} features={features}\n onApiReady={(next) => (api = next)} />\n\n<button onclick={save}>Save layout</button>\n<button onclick={restore}>Restore layout</button>\n```\n\n`getColumnWidths()` returns every column's *current effective* width\n(user resize OR columnDef `width` OR grid-wide default), so the\nsnapshot round-trips cleanly.\n\n## Auto-fit\n\n`fitColumns={true}` scales every column proportionally to fill the\nviewport - the wrapper handles rounding-residue absorption + a modest\nshrink-to-fit (down to ~85 % of natural widths). For \"fit to content\"\n(longest cell text), pre-compute per-column widths once after a data\nload and call `api.setColumnWidth(id, w)` per column.\n\n## Column virtualization\n\nWith many columns, enable column virtualization so only the visible columns\nrender:\n\n```svelte\n<SvGrid\n {columns}\n {data}\n features={{}}\n columnVirtualization={true}\n columnWidth={120}\n columnOverscan={3}\n/>\n```\n\nSee [examples/src/demos/06-large-dataset.svelte](../../../examples/src/demos/06-large-dataset.svelte)\nfor a 100-column virtualized grid.\n\n## Gotchas\n\n- A `width` set in the column def is an **initial** width. Once a user has\n resized, the override on disk wins. The library does not expose the\n current widths to outside code; if you need to persist them, file an\n issue (or PR) for `getColumnWidths()` on `SvGridApi`.\n\n## See also\n\n- [Column moving](./column-moving.md)\n- [Column pinning](./column-pinning.md)\n"
3227
+ "markdown": "# Column sizing\n\nEach column has a pixel width. The default for all columns is the grid's\n`columnWidth` prop (default ~140 px); each column can override via its\n`width` field.\n<div data-docs-demo=\"63-column-layout-api\" data-height=\"540\"></div>\n\n```ts\nconst columns: ColumnDef<{}, Person>[] = [\n { field: 'firstName', header: 'First name', width: 150 },\n { field: 'department', header: 'Department', width: 180 },\n { field: 'salary', header: 'Salary', width: 120 },\n]\n```\n\n```svelte\n<SvGrid {columns} {data} features={{}} columnWidth={140} />\n```\n\n## User resizing\n\nEvery column has a resize handle on its right edge. Drag to widen /\nnarrow; the minimum is 40 px. Resizes are stored per column id inside\nthe grid component.\n\nThere is no opt-out today - if you do not want users to resize a column,\noverlay your own pointer-blocking element on the header or wrap in CSS:\n\n```css\ntable[role='grid'] th[data-col-id=\"firstName\"] .sv-grid-resize-handle {\n pointer-events: none;\n}\n```\n\n## Programmatic resizing + persistence\n\nThe imperative API exposes `setColumnWidth` and `getColumnWidths`:\n\n```svelte\n<script lang=\"ts\">\n import type { SvGridApi } from '@svgrid/grid'\n let api = $state<SvGridApi<typeof features, Person> | null>(null)\n\n function save() {\n if (!api) return\n localStorage.setItem('widths', JSON.stringify(api.getColumnWidths()))\n }\n function restore() {\n if (!api) return\n const saved: Record<string, number> = JSON.parse(localStorage.getItem('widths') ?? '{}')\n for (const [id, w] of Object.entries(saved)) api.setColumnWidth(id, w)\n }\n</script>\n\n<SvGrid {data} {columns} features={features}\n onApiReady={(next) => (api = next)} />\n\n<button onclick={save}>Save layout</button>\n<button onclick={restore}>Restore layout</button>\n```\n\n`getColumnWidths()` returns every column's *current effective* width\n(user resize OR columnDef `width` OR grid-wide default), so the\nsnapshot round-trips cleanly.\n\n## Auto-fit\n\n`fitColumns={true}` scales every column proportionally to fill the\nviewport - the wrapper handles rounding-residue absorption + a modest\nshrink-to-fit (down to ~85 % of natural widths). For \"fit to content\"\n(longest cell text), pre-compute per-column widths once after a data\nload and call `api.setColumnWidth(id, w)` per column.\n\n## Column virtualization\n\nWith many columns, enable column virtualization so only the visible columns\nrender:\n\n```svelte\n<SvGrid\n {columns}\n {data}\n features={{}}\n columnVirtualization={true}\n columnWidth={120}\n columnOverscan={3}\n/>\n```\n\nSee [examples/src/demos/06-large-dataset.svelte](../../../examples/src/demos/06-large-dataset.svelte)\nfor a 100-column virtualized grid.\n\n## Gotchas\n\n- A `width` set in the column def is an **initial** width. Once a user has\n resized, the override on disk wins. The library does not expose the\n current widths to outside code; if you need to persist them, file an\n issue (or PR) for `getColumnWidths()` on `SvGridApi`.\n\n## See also\n\n- [Column moving](./column-moving.md)\n- [Column pinning](./column-pinning.md)\n"
3216
3228
  },
3217
3229
  {
3218
3230
  "slug": "help/columns/column-spanning",
@@ -3248,7 +3260,7 @@ export const docs = [
3248
3260
  "slug": "help/comparison",
3249
3261
  "path": "docs/help/comparison.md",
3250
3262
  "title": "Comparison: SvGrid vs AG Grid vs TanStack Table",
3251
- "markdown": "# Comparison: SvGrid vs AG Grid vs TanStack Table\r\n\r\nThe three projects solve overlapping problems, and the right choice\r\ndepends on the framework you ship on and your budget.\r\n\r\n## TL;DR\r\n\r\n| Project | Lives in | Ships | Bundle (typical) | License |\r\n| -------------------- | ---------------------------------------- | --------------------------------------- | ---------------- | ------------------ |\r\n| **SvGrid** | Svelte 5 | Headless core + Svelte render + Enterprise pack | ~2 KB headless / ~78 KB full (gzip) | MIT (Community) / commercial (Enterprise) |\r\n| **AG Grid Community**| React, Angular, Vue, plain JS | Full grid + renderer | ~340 KB | MIT |\r\n| **AG Grid Enterprise**| same | Adds pivot, integrated charts, server-side row model, more | ~600 KB+ | Commercial |\r\n| **TanStack Table** | React, Vue, Svelte, Solid, Qwik, Lit, JS | Headless engine **only** | ~12-14 KB | MIT |\r\n\r\n## When SvGrid is the right choice\r\n\r\n- You're on **Svelte 5** and want a grid that uses the runtime's idioms\r\n (snippets for cells, `$state` for data, `$derived` for aggregates) -\r\n not a React-port pretending to be Svelte.\r\n- You want a **headless core you can render yourself** AND a\r\n default-styled component for the 80% case. Most \"headless\" libraries\r\n make you write the markup; most \"monolith\" libraries make you fight\r\n the markup. SvGrid does both in one package.\r\n- You need **clean theming via CSS custom properties** and a documented\r\n `--sg-*` token surface, not a hard-coded class soup.\r\n- You ship under **strict CSP** (no `eval`, no `new Function`, no\r\n inline scripts). SvGrid runs clean; AG Grid Community does too.\r\n TanStack Table is engine-only so the question doesn't apply.\r\n- You want **SSR markup that is meaningful before hydration** (good\r\n first paint, SEO, SvelteKit `+page.server` integration). SvGrid +\r\n TanStack Table both qualify. AG Grid renders client-side.\r\n\r\n## When AG Grid is the right choice\r\n\r\n- You're on **React, Angular, or Vue**, not Svelte. SvGrid is\r\n Svelte-only.\r\n- You need **every grid feature shipped** out of the box: row drag,\r\n master-detail with built-in API, range selection, status bar,\r\n context menu, column tool panel, integrated charts (Enterprise),\r\n Excel-native pivot UI (Enterprise), server-side row model\r\n (Enterprise).\r\n- You need **enterprise commercial support** with SLAs. AG sells it;\r\n SvGrid Enterprise support is best-effort.\r\n\r\n## When TanStack Table is the right choice\r\n\r\n- You want a **rendering-framework-agnostic engine** so the same\r\n business logic powers React + Svelte + Solid surfaces in your\r\n monorepo.\r\n- You're already in the TanStack ecosystem (Query, Router, Form,\r\n Virtual) and want one mental model.\r\n- You're happy writing **all the markup yourself** - the row recycling,\r\n the keyboard map, the ARIA roles, the focus management, the\r\n drag-to-resize. That's the cost of \"engine only\".\r\n\r\n## Feature parity at a glance\r\n\r\n| | SvGrid Community | SvGrid Enterprise | AG Grid Community | AG Grid Enterprise | TanStack Table |\r\n| ------------------------------- | ---------------- | ---------- | ----------------- | ------------------ | -------------- |\r\n| Headless core (engine only) | ✓ | ✓ | - | - | ✓ |\r\n| Default render component | ✓ (Svelte 5) | ✓ | ✓ (each FW) | ✓ | - |\r\n| Sort (multi-column) | ✓ | ✓ | ✓ | ✓ | ✓ |\r\n| Filter menu (operator + facet) | ✓ | ✓ | ✓ | ✓ | - |\r\n| Filter row | ✓ | ✓ | ✓ | ✓ | - |\r\n| Pagination | ✓ | ✓ | ✓ | ✓ | ✓ |\r\n| Grouping + aggregation | ✓ | ✓ | basic | ✓ (advanced) | ✓ (engine) |\r\n| Tree / expand-collapse rows | ✓ | ✓ | basic | ✓ | ✓ |\r\n| Cell range selection + copy/paste | ✓ | ✓ | ✓ (Enterprise) | ✓ | - |\r\n| Inline editing (5 editor types) | ✓ | ✓ | ✓ | ✓ | - |\r\n| Column virtualization | ✓ | ✓ | ✓ | ✓ | - |\r\n| Row virtualization | ✓ | ✓ | ✓ | ✓ | - |\r\n| Column pinning (left/right) | ✓ | ✓ | ✓ | ✓ | - |\r\n| Fit-to-width with shrink | ✓ | ✓ | partial | ✓ | - |\r\n| WAI-ARIA grid pattern | ✓ | ✓ | ✓ | ✓ | - |\r\n| Server-side row model | external mode | external mode | - | ✓ (built-in) | external mode |\r\n| CSP-clean (no eval, no inline) | ✓ | ✓ | ✓ | ✓ | n/a |\r\n| Meaningful SSR markup | ✓ | ✓ | - | - | depends on FW |\r\n| Excel / PDF / CSV export | - | ✓ | - | ✓ (Enterprise) | - |\r\n| Excel / CSV import | - | ✓ | - | - | - |\r\n| AI assistant | - | ✓ (BYO provider) | - | - | - |\r\n| Pivot table | - | ✓ | - | ✓ | (custom) |\r\n| Integrated charts | - | - | - | ✓ | - |\r\n| Theming via CSS variables | ✓ (`--sg-*`) | ✓ | ✓ (theme builder) | ✓ | n/a |\r\n| Source-button per demo | ✓ (gallery) | ✓ | - | - | - |\r\n\r\n## Bundle size\r\n\r\nMeasured gzipped, with Svelte treated as a peer dependency and excluded\r\n(the bundlephobia convention):\r\n\r\n| @svgrid/grid path | Gzipped | Minified |\r\n| ----------------------------------------------- | ------- | -------- |\r\n| Headless core (`createGrid` + a row model) | ~2 KB | ~7 KB |\r\n| Full `<SvGrid>` render component | ~78 KB | ~340 KB |\r\n\r\nAdd ~9 KB gzipped for the render component's CSS. A further ~64 KB of\r\ncharts, date/time editors, menus, and export splits into `import()` chunks\r\nthat load on demand rather than shipping in your initial bundle. Re-measure\r\nany time with `node packages/grid/scripts/measure-size.mjs`; see the\r\n[bundle size reference](../reference/bundle-size.md).\r\n\r\nThe full render component is the whole grid - virtualization, Excel-style\r\nfilters, inline editing, grouping, tree, master/detail, and accessibility -\r\nin one import. For reference, the headless core is lighter than TanStack\r\nTable's headless engine (~12-14 KB), and the render component is a fraction\r\nof AG Grid Community (commonly cited around 340 KB minified). `@svgrid/enterprise`\r\nfeatures are separate subpath imports that lazy-load, so they add nothing to\r\nyour initial bundle until used.\r\n\r\n## Migrating from AG Grid\r\n\r\nThe most common starting point. See\r\n[Migrating from AG Grid](./migrating-from-ag-grid.md) for a\r\n30-minute, side-by-side translation of column defs, features,\r\nfiltering, editing, and the imperative API.\r\n\r\n## Migrating from TanStack Table\r\n\r\nThe map is one-to-one - SvGrid's headless core is API-compatible with\r\nTanStack Table's React adapter in 90% of cases. The big differences:\r\n\r\n- Replace `useReactTable(opts)` with `createSvGrid(opts)`. Identical\r\n state machine.\r\n- Replace `getCoreRowModel()` calls with the same name from\r\n `@svgrid/grid`.\r\n- The render layer changes - TanStack hands you `flexRender` + the\r\n row model; SvGrid lets you keep that headless approach OR drop in\r\n the default `<SvGrid>` component.\r\n\r\n## Pricing\r\n\r\nSvGrid Community is MIT - free for commercial use, no attribution\r\nrequired at runtime. SvGrid Enterprise is a paid license; see\r\n<https://svgrid.com/pricing/> for per-seat / per-app / multi-app tiers.\r\n\r\nAG Grid Community is MIT. AG Grid Enterprise pricing is on\r\nag-grid.com; expect a per-developer annual license plus a separate\r\ndeployment license for production.\r\n\r\nTanStack Table is MIT.\r\n\r\n## See also\r\n\r\n- [Why headless?](../why-headless.md) - the design decision behind\r\n SvGrid's two-layer architecture.\r\n- [Migrating from AG Grid](./migrating-from-ag-grid.md) - the\r\n practical recipe.\r\n- [Enterprise feature pack](../enterprise/README.md) - what SvGrid charges for.\r\n- [Missing features](./missing-features.md) - the honest gap list\r\n versus AG Grid Enterprise.\r\n\r\n## Frequently asked questions\r\n\r\n### What is the best data grid for Svelte 5?\r\n\r\nFor a Svelte-5-native grid with a batteries-included render component, SvGrid is\r\nbuilt around runes and snippets. TanStack Table is a strong headless-only\r\nchoice if you want to build the DOM layer yourself across frameworks. AG Grid is\r\nthe most feature-complete but lives in React/Angular/Vue first and is heavy to\r\nbridge into Svelte 5.\r\n\r\n### Is SvGrid a good AG Grid alternative?\r\n\r\nYes, for Svelte projects. SvGrid ships a much smaller bundle (~78 KB gzipped\r\nfor the full render component, ~2 KB headless) than AG Grid Community, is\r\nMIT-licensed for commercial use, and offers `@svgrid/enterprise` for\r\nexport/pivot/import at a per-developer price instead of AG Grid Enterprise's\r\nper-deployment licensing. It does not yet match every AG Grid Enterprise\r\nfeature - see the missing-features list for the honest gaps.\r\n\r\n### SvGrid vs TanStack Table - which should I pick?\r\n\r\nPick SvGrid if you want virtualization, Excel-style filters, selection, and\r\ninline editing working out of the box on Svelte 5. Pick TanStack Table if you\r\nwant a framework-agnostic headless engine and are happy to build the rendering,\r\nvirtualization, and editing UI yourself. Both are MIT-licensed.\r\n\r\n### How big is the SvGrid bundle?\r\n\r\nMeasured gzipped (Svelte excluded as a peer dependency): ~2 KB for the\r\nheadless core and ~78 KB for the full `<SvGrid>` render component (~340 KB\r\nminified), plus ~9 KB of CSS. Charts, date/time editors, menus, and export\r\nadd another ~64 KB that loads on demand rather than up front. Enterprise\r\nfeatures are separate, lazy-loaded subpath imports, so you ship only what\r\nyou import.\r\n"
3263
+ "markdown": "# Comparison: SvGrid vs AG Grid vs TanStack Table\r\n\r\nThe three projects solve overlapping problems, and the right choice\r\ndepends on the framework you ship on and your budget.\r\n\r\n## TL;DR\r\n\r\n| Project | Lives in | Ships | Bundle (typical) | License |\r\n| -------------------- | ---------------------------------------- | --------------------------------------- | ---------------- | ------------------ |\r\n| **SvGrid** | Svelte 5 | Headless core + Svelte render + Enterprise pack | ~2 KB headless / ~77 KB full (gzip) | MIT (Community) / commercial (Enterprise) |\r\n| **AG Grid Community**| React, Angular, Vue, plain JS | Full grid + renderer | ~340 KB | MIT |\r\n| **AG Grid Enterprise**| same | Adds pivot, integrated charts, server-side row model, more | ~600 KB+ | Commercial |\r\n| **TanStack Table** | React, Vue, Svelte, Solid, Qwik, Lit, JS | Headless engine **only** | ~12-14 KB | MIT |\r\n\r\n## When SvGrid is the right choice\r\n\r\n- You're on **Svelte 5** and want a grid that uses the runtime's idioms\r\n (snippets for cells, `$state` for data, `$derived` for aggregates) -\r\n not a React-port pretending to be Svelte.\r\n- You want a **headless core you can render yourself** AND a\r\n default-styled component for the 80% case. Most \"headless\" libraries\r\n make you write the markup; most \"monolith\" libraries make you fight\r\n the markup. SvGrid does both in one package.\r\n- You need **clean theming via CSS custom properties** and a documented\r\n `--sg-*` token surface, not a hard-coded class soup.\r\n- You ship under **strict CSP** (no `eval`, no `new Function`, no\r\n inline scripts). SvGrid runs clean; AG Grid Community does too.\r\n TanStack Table is engine-only so the question doesn't apply.\r\n- You want **SSR markup that is meaningful before hydration** (good\r\n first paint, SEO, SvelteKit `+page.server` integration). SvGrid +\r\n TanStack Table both qualify. AG Grid renders client-side.\r\n\r\n## When AG Grid is the right choice\r\n\r\n- You're on **React, Angular, or Vue**, not Svelte. SvGrid is\r\n Svelte-only.\r\n- You need **server-side pivoting**, or a push-based **viewport row model**\r\n for a real-time trading blotter. SvGrid ships pivot, charts and a\r\n server-side row model (sort / filter / group / infinite), but not those\r\n two.\r\n- You need **pluggable custom filter components or custom tool panels**.\r\n SvGrid's tool panel is a fixed Columns + Filters pair.\r\n- You want a vendor with **a decade of enterprise procurement paperwork**\r\n already on file.\r\n\r\n## When TanStack Table is the right choice\r\n\r\n- You want a **rendering-framework-agnostic engine** so the same\r\n business logic powers React + Svelte + Solid surfaces in your\r\n monorepo.\r\n- You're already in the TanStack ecosystem (Query, Router, Form,\r\n Virtual) and want one mental model.\r\n- You're happy writing **all the markup yourself** - the row recycling,\r\n the keyboard map, the ARIA roles, the focus management, the\r\n drag-to-resize. That's the cost of \"engine only\".\r\n\r\n## Feature parity at a glance\r\n\r\n| | SvGrid Community | SvGrid Enterprise | AG Grid Community | AG Grid Enterprise | TanStack Table |\r\n| ------------------------------- | ---------------- | ---------- | ----------------- | ------------------ | -------------- |\r\n| Headless core (engine only) | ✓ | ✓ | - | - | ✓ |\r\n| Default render component | ✓ (Svelte 5) | ✓ | ✓ (each FW) | ✓ | - |\r\n| Sort (multi-column) | ✓ | ✓ | ✓ | ✓ | ✓ |\r\n| Filter menu (operator + facet) | ✓ | ✓ | ✓ | ✓ | - |\r\n| Filter row | ✓ | ✓ | ✓ | ✓ | - |\r\n| Pagination | ✓ | ✓ | ✓ | ✓ | ✓ |\r\n| Grouping + aggregation | ✓ | ✓ | basic | ✓ (advanced) | ✓ (engine) |\r\n| Tree / expand-collapse rows | ✓ | ✓ | basic | ✓ | ✓ |\r\n| Cell range selection + copy/paste | ✓ | ✓ | ✓ (Enterprise) | ✓ | - |\r\n| Inline editing (5 editor types) | ✓ | ✓ | ✓ | ✓ | - |\r\n| Column virtualization | ✓ | ✓ | ✓ | ✓ | - |\r\n| Row virtualization | ✓ | ✓ | ✓ | ✓ | - |\r\n| Column pinning (left/right) | ✓ | ✓ | ✓ | ✓ | - |\r\n| Fit-to-width with shrink | ✓ | ✓ | partial | ✓ | - |\r\n| WAI-ARIA grid pattern | ✓ | ✓ | ✓ | ✓ | - |\r\n| Server-side row model | ✓ (built-in) | ✓ (built-in) | - | ✓ (built-in) | external mode |\r\n| CSP-clean (no eval, no inline) | ✓ | ✓ | ✓ | ✓ | n/a |\r\n| Meaningful SSR markup | ✓ | ✓ | - | - | depends on FW |\r\n| Excel / PDF / CSV export | - | ✓ | - | ✓ (Enterprise) | - |\r\n| Excel / CSV import | - | ✓ | - | - | - |\r\n| AI assistant | - | ✓ (BYO provider) | - | - | - |\r\n| Pivot table | - | ✓ | - | ✓ | (custom) |\r\n| Integrated charts | ✓ | ✓ | - | ✓ (paid add-on) | - |\r\n| Runtime dependencies | 0 | 0 | 2 | 2 | 1 |\r\n| Support response time | GitHub (best-effort) | 1 business day; 1h sev-1 on Enterprise | GitHub (best-effort) | no number published | GitHub (best-effort) |\r\n| Theming via CSS variables | ✓ (`--sg-*`) | ✓ | ✓ (theme builder) | ✓ | n/a |\r\n| Source-button per demo | ✓ (gallery) | ✓ | - | - | - |\r\n\r\n## Bundle size\r\n\r\nMeasured gzipped, with Svelte treated as a peer dependency and excluded\r\n(the bundlephobia convention):\r\n\r\n| @svgrid/grid path | Gzipped | Minified |\r\n| ----------------------------------------------- | ------- | -------- |\r\n| Headless core (`createGrid` + a row model) | ~2 KB | ~7 KB |\r\n| Full `<SvGrid>` render component | ~77 KB | ~340 KB |\r\n\r\nAdd ~9 KB gzipped for the render component's CSS. A further ~64 KB of\r\ncharts, date/time editors, menus, and export splits into `import()` chunks\r\nthat load on demand rather than shipping in your initial bundle. Re-measure\r\nany time with `node packages/grid/scripts/measure-size.mjs`; see the\r\n[bundle size reference](../reference/bundle-size.md).\r\n\r\nThe full render component is the whole grid - virtualization, Excel-style\r\nfilters, inline editing, grouping, tree, master/detail, and accessibility -\r\nin one import. For reference, the headless core is lighter than TanStack\r\nTable's headless engine (~12-14 KB), and the render component is a fraction\r\nof AG Grid Community (commonly cited around 340 KB minified). `@svgrid/enterprise`\r\nfeatures are separate subpath imports that lazy-load, so they add nothing to\r\nyour initial bundle until used.\r\n\r\n## Migrating from AG Grid\r\n\r\nThe most common starting point. See\r\n[Migrating from AG Grid](./migrating-from-ag-grid.md) for a\r\n30-minute, side-by-side translation of column defs, features,\r\nfiltering, editing, and the imperative API.\r\n\r\n## Migrating from TanStack Table\r\n\r\nThe map is one-to-one - SvGrid's headless core is API-compatible with\r\nTanStack Table's React adapter in 90% of cases. The big differences:\r\n\r\n- Replace `useReactTable(opts)` with `createSvGrid(opts)`. Identical\r\n state machine.\r\n- Replace `getCoreRowModel()` calls with the same name from\r\n `@svgrid/grid`.\r\n- The render layer changes - TanStack hands you `flexRender` + the\r\n row model; SvGrid lets you keep that headless approach OR drop in\r\n the default `<SvGrid>` component.\r\n\r\n## Pricing\r\n\r\nSvGrid Community is MIT - free for commercial use, no attribution\r\nrequired at runtime. SvGrid Enterprise is a paid license; see\r\n<https://svgrid.com/pricing/> for per-seat / per-app / multi-app tiers.\r\n\r\nAG Grid Community is MIT. AG Grid Enterprise pricing is on\r\nag-grid.com; expect a per-developer annual license plus a separate\r\ndeployment license for production.\r\n\r\nTanStack Table is MIT.\r\n\r\n## See also\r\n\r\n- [Why headless?](../why-headless.md) - the design decision behind\r\n SvGrid's two-layer architecture.\r\n- [Migrating from AG Grid](./migrating-from-ag-grid.md) - the\r\n practical recipe.\r\n- [Enterprise feature pack](../enterprise/README.md) - what SvGrid charges for.\r\n- [Missing features](./missing-features.md) - the honest gap list\r\n versus AG Grid Enterprise.\r\n\r\n## Frequently asked questions\r\n\r\n### What is the best data grid for Svelte 5?\r\n\r\nFor a Svelte-5-native grid with a batteries-included render component, SvGrid is\r\nbuilt around runes and snippets. TanStack Table is a strong headless-only\r\nchoice if you want to build the DOM layer yourself across frameworks. AG Grid is\r\nthe most feature-complete but lives in React/Angular/Vue first and is heavy to\r\nbridge into Svelte 5.\r\n\r\n### Is SvGrid a good AG Grid alternative?\r\n\r\nYes, for Svelte projects. SvGrid ships a much smaller bundle (~77 KB gzipped\r\nfor the full render component, ~2 KB headless) than AG Grid Community, is\r\nMIT-licensed for commercial use, and offers `@svgrid/enterprise` for\r\nexport/pivot/import at a per-developer price instead of AG Grid Enterprise's\r\nper-deployment licensing. It does not yet match every AG Grid Enterprise\r\nfeature - see the missing-features list for the honest gaps.\r\n\r\n### SvGrid vs TanStack Table - which should I pick?\r\n\r\nPick SvGrid if you want virtualization, Excel-style filters, selection, and\r\ninline editing working out of the box on Svelte 5. Pick TanStack Table if you\r\nwant a framework-agnostic headless engine and are happy to build the rendering,\r\nvirtualization, and editing UI yourself. Both are MIT-licensed.\r\n\r\n### How big is the SvGrid bundle?\r\n\r\nMeasured gzipped (Svelte excluded as a peer dependency): ~2 KB for the\r\nheadless core and ~77 KB for the full `<SvGrid>` render component (~340 KB\r\nminified), plus ~9 KB of CSS. Charts, date/time editors, menus, and export\r\nadd another ~64 KB that loads on demand rather than up front. Enterprise\r\nfeatures are separate, lazy-loaded subpath imports, so you ship only what\r\nyou import.\r\n"
3252
3264
  },
3253
3265
  {
3254
3266
  "slug": "help/conditional-form-schema",
@@ -3328,6 +3340,12 @@ export const docs = [
3328
3340
  "title": "Expression query language - Enterprise",
3329
3341
  "markdown": "# Expression query language - Enterprise\n\nThe expression language is a small, dependency-free query language your users\nauthor through the UI. It is the connective tissue behind [alerts](./alerts.md)\n(and, in time, styled and calculated columns): one model for predicates, scalar\nmaths, and change detection, evaluated the same way everywhere.\n\nIt ships in `@svgrid/enterprise`. Leaf comparisons delegate to the grid's own\n`applyExcelFilter`, so an expression's operators mean exactly what the column\nfilter menu means.\n\n## The editor\n\n`<SvExpressionEditor>` offers two modes over the same value:\n\n- **Builder** (default) - a list of conditions (column + operator + value)\n combined with *all* (AND) or *any* (OR), just like the filter row. Set\n operators show a token input; `between` shows two fields; blank operators show\n none.\n- **Text** - a free-text box for power users, with live validation and column\n maths.\n\nBoth show a live \"matches N of M\" count against a sample `rows` array.\n\n```svelte\n<script lang=\"ts\">\n import { SvExpressionEditor, type ExprColumn, type PredicateExpr } from '@svgrid/enterprise'\n\n const columns: ExprColumn[] = [\n { id: 'price', name: 'Price', type: 'number' },\n { id: 'region', name: 'Region', type: 'text' },\n ]\n let expr = $state<PredicateExpr>({ kind: 'const', value: true })\n</script>\n\n<SvExpressionEditor {columns} bind:value={expr} rows={sample} />\n```\n\n## Text syntax\n\nColumn references are a bare id (`price`) or a bracketed label (`[Unit Price]`)\nwhen the name has spaces.\n\n```\nprice > 100 AND region IN (\"EU\", \"US\")\nname CONTAINS \"widget\" OR name STARTSWITH \"gadget\"\nprice BETWEEN 10 AND 20\nname ISBLANK\nprice / qty >= 40 -- cross-column maths\nSUM(amount) > 10000 -- aggregate over the rows in scope\n```\n\n- Logic: `AND`, `OR`, `NOT`, parentheses.\n- Text/set/range operators: `CONTAINS`, `STARTSWITH`, `ENDSWITH`, `MATCHES`\n (regex), `IN (…)`, `BETWEEN … AND …`, `ISBLANK`, `ISNOTBLANK`.\n- Symbolic comparators: `=`, `!=`, `>`, `<`, `>=`, `<=` (these also drive\n cross-column maths).\n- Arithmetic: `+`, `-`, `*`, `/`, `%`, unary `-`.\n- Aggregates: `SUM(col)`, `AVG(col)`, `COUNT(col)`.\n- Functions: `ABS`, `ROUND`, `FLOOR`, `CEIL`, `MIN`, `MAX`, `IF`, `COALESCE`,\n `CONCAT`, `LOWER`, `UPPER`, `LEN`.\n\nA `column = literal` comparison parses to a grid-filter `cmp` node (so it folds\naccents/case like the filter row); general comparisons and maths parse to a\n`scalarCmp`.\n\n## The model\n\nThe canonical form is a JSON AST - no parser is needed at runtime. Three\nfamilies:\n\n- **`PredicateExpr`** - boolean: `and` / `or` / `not` / `cmp` (a filter-operator\n test) / `scalarCmp` (compare two scalar expressions) / `const`.\n- **`ScalarExpr`** - a single value: `col` / `lit` / `bin` (arithmetic) / `agg` /\n `func`.\n- **`ChangeExpr`** - `changed` / `delta` / `percentChange` / `crossed`, for the\n \"relative change\" alert trigger.\n\n## Evaluating expressions\n\n```ts\nimport { evaluatePredicate, parsePredicate, validateExpression } from '@svgrid/enterprise'\n\nconst expr = parsePredicate('price > 100 AND region = \"EU\"', columns)\nvalidateExpression(expr, columns) // [] when sound\nevaluatePredicate(expr, { row: { price: 120, region: 'EU' } }) // true\n```\n\n`evaluateScalar` and `evaluateChange` cover the other two families. All three are\npure functions - safe to unit-test and to run in a Web Worker.\n\nAdvanced scalar functions can be injected per evaluation via the context's\n`functions` map (for example, to delegate a formula to HyperFormula) without\nmaking it a hard dependency.\n\n## See also\n\n- [Alerts](./alerts.md) - the flagship consumer of this language.\n- [Filtering](./filtering/overview.md) - the column filter operators the `cmp` nodes reuse.\n"
3330
3342
  },
3343
+ {
3344
+ "slug": "help/filtering/advanced-filter",
3345
+ "path": "docs/help/filtering/advanced-filter.md",
3346
+ "title": "Advanced filter",
3347
+ "markdown": "# Advanced filter\n\nThe column filters cover one column at a time, joined with AND. The advanced\nfilter is for the questions that shape does not reach: OR across different\ncolumns, negation, nested groups, and comparisons against an aggregate of the\nrows themselves.\n\n<div data-docs-demo=\"98-advanced-filter-builder\" data-height=\"620\"></div>\n\nThe engine ships in `@svgrid/enterprise`. The free grid carries the config type\nand the seam to plug an engine in, so nothing in `@svgrid/grid` depends on the\ncommercial package.\n\n## Setup\n\n```svelte\n<script>\n import { SvGrid } from '@svgrid/grid'\n import { SvAdvancedFilter, enableAdvancedFilter } from '@svgrid/enterprise'\n\n enableAdvancedFilter()\n let api = $state(null)\n</script>\n\n{#if api}\n <SvAdvancedFilter {api} />\n{/if}\n<SvGrid {data} {columns} onApiReady={(a) => (api = a)} />\n```\n\nThe panel is mounted by you, beside the grid, the same way `SvGridAlerts` is.\nThere is no grid-side renderer to register, so you decide whether it sits in a\nsidebar, a drawer or a dialog.\n\n## The expression\n\nAn expression is a JSON AST, so a saved view is just data:\n\n```ts\nconst expr = {\n kind: 'and',\n parts: [\n { kind: 'cmp', column: 'region', op: 'equals', value: 'EMEA' },\n { kind: 'cmp', column: 'churnRisk', op: 'in', value: ['medium', 'high'] },\n ],\n}\napi.setAdvancedFilter(expr)\n```\n\n`cmp` uses the **same operator union as the filter row**, and leaf comparisons\ndelegate to the grid's own `applyExcelFilter`. A \"greater than\" in the advanced\nfilter is byte-for-byte a \"greater than\" in the column menu, rather than a\nsecond implementation that drifts.\n\n| Node | Means |\n| --- | --- |\n| `cmp` | One column compared with a filter-row operator |\n| `and` / `or` | Combine parts |\n| `not` | Negate |\n| `scalarCmp` | Compare two scalar expressions, so column maths and aggregates |\n| `const` | `{ kind: 'const', value: true }` is how \"no filter\" is spelled |\n\n### Comparing a row to an aggregate\n\nThis is the one the column filters cannot express at all, because it compares\neach row to a value computed from the rows that survived the other filters:\n\n```ts\napi.setAdvancedFilter({\n kind: 'scalarCmp',\n left: { kind: 'col', id: 'arr' },\n op: '>',\n right: { kind: 'agg', fn: 'avg', column: 'arr' },\n})\n```\n\nThe aggregate is folded **once per filter change**, not once per row. A naive\nevaluator re-scans the row set for every row, which makes `SUM(x) > N`\nquadratic; at a few thousand rows that is the difference between instant and\nvisibly stuck.\n\n## The builder\n\nThe panel edits the expression as a tree: conditions, and groups holding more\nconditions. **Add group** nests one level, and a nested group defaults to the\nopposite combinator of its parent, since nesting an \"all\" inside an \"all\" means\nnothing and would only have to be corrected. **NOT** on a group negates it.\n\nNesting is capped at four levels. Beyond that the panel keeps the expression in\ntext mode rather than drawing a tree too deep to read.\n\n### What the builder will not show\n\nColumn maths and aggregates have no condition-row representation, so an\nexpression containing them stays in text mode and the **Builder** tab is\ndisabled with a tooltip explaining why. That is deliberate: a tab that looks\nclickable and then refuses reads as a bug, when the refusal is a property of\nthe expression.\n\n`not` around a *single* condition is also left to text mode. The grid already\nhas negative operators (`notEquals`, `notContains`), and offering two spellings\nof one thing in the builder makes the UI worse, not better.\n\nText mode always accepts everything:\n\n```\nregion = \"EMEA\" AND (arr > 300000 OR seats > 150)\n```\n\n## Applying, and what happens on failure\n\nThe panel holds a **draft** and only touches the grid on **Apply**. The editor\nemits on every keystroke, which is right for an alert rule but here would re-run\nthe whole filter pipeline per character. Holding the draft also makes the live\n\"matches N\" counter a real preview of what Apply would do rather than a lagging\necho of what already happened.\n\nFiltering **fails open**. If no engine is registered, if the expression fails to\ncompile, or if anything throws, the rows are left untouched:\n\n> A half-filtered grid is indistinguishable from a correctly filtered one. Silently\n> dropping rows because of an internal error is the one outcome worth ruling out,\n> so the failure is visible instead: the panel says an expression is set but no\n> engine is running it.\n\nThat is also why `@svgrid/grid` alone shows every row when you set an expression\nwithout `enableAdvancedFilter()` - the free package never pretends to filter.\n\n## The toolbar indicator\n\nWhen an advanced filter is set, the grid shows a chip above the table naming it,\nwith a control to clear it. The filter is authored in a panel you placed, which\nmay be scrolled away or behind a drawer; without the chip, rows are missing with\nnothing on screen to say why. Both strings go through `localeText`\n(`advancedFilterActive`, `advancedFilterClear`).\n\n## Server-side\n\n`ServerFilterModel.expression` carries the expression to your backend. The\ncontract is **all-or-nothing**: apply the whole expression and set\n`appliedExpression: true`, or apply none of it.\n\nIf a backend ignores it, the grid does **not** filter the page it already has.\nDoing so would turn \"3 of 1,000,000 match\" into a confident lie, and paging\nwould be incoherent because the next page would re-filter a different slice.\nInstead the state carries `expressionUnapplied`, the grid warns once, and the\nrows are left alone so you can tell the user the filter did not run.\n\nSee [server-side filtering](../server/server-filtering.md).\n\n## API\n\n| Method | Does |\n| --- | --- |\n| `api.setAdvancedFilter(expr)` | Apply an expression |\n| `api.getAdvancedFilter()` | The current expression, or `null` |\n| `api.clearAdvancedFilter()` | Remove it |\n| `api.isAdvancedFilterActive()` | True only when an engine is actually running it |\n\n`api.clearAllFilters()` clears the advanced filter too, since it promises every\nfilter surface. Saved views round-trip it through an optional `advancedFilter`\nkey, so views saved before this feature existed load unchanged.\n\n## See also\n\n- [Filtering overview](./overview.md)\n- [Filter conditions](./filter-conditions.md) - the per-column shape\n- [Filter API](./filter-api.md)\n"
3348
+ },
3331
3349
  {
3332
3350
  "slug": "help/filtering/applying-filters",
3333
3351
  "path": "docs/help/filtering/applying-filters.md",
@@ -3374,7 +3392,7 @@ export const docs = [
3374
3392
  "slug": "help/filtering/overview",
3375
3393
  "path": "docs/help/filtering/overview.md",
3376
3394
  "title": "Filtering - overview",
3377
- "markdown": "# Filtering - overview\n\nClick any column header's filter icon to open the operator + value\npopover; numeric and date columns range-bucket their distinct values\nso the menu stays usable on big datasets:\n\n![Per-column filters and the quick filter collapse into one filterModel that the engine or your ServerDataSource applies to produce the filtered rows.](/docs-media/grid-filter-model.svg)\n\n<div data-docs-demo=\"03-excel-filters\" data-height=\"460\"></div>\n\nSvGrid offers four filtering surfaces. You opt into the one(s) you need\nthrough the `filterMode` prop on `<SvGrid>`:\n\n| `filterMode` | What it shows |\n| ------------ | ------------- |\n| `'menu'` (default) | A \"filter icon\" in each header opens a per-column operator + value popover. |\n| `'row'` | A filter row under the header - one input per column. |\n| `'global'` | A single search box above the grid that searches all visible columns. |\n| `'none'` | No filter UI. Drive filters programmatically only. |\n\n```svelte\n<SvGrid {data} {columns} features={features} filterMode=\"row\" />\n```\n\nPer-surface props (`showColumnFilters`, `showFilterRow`, `showGlobalFilter`)\noverride `filterMode` when set explicitly - useful when you want two\nsurfaces simultaneously.\n\n## Feature registration\n\nFiltering is gated by `columnFilteringFeature` plus\n`createFilteredRowModel`. Both must be registered for the column filter UI\nto actually filter rows:\n\n```ts\nimport {\n tableFeatures, columnFilteringFeature, createFilteredRowModel,\n} from '@svgrid/grid'\n\nconst features = tableFeatures({ columnFilteringFeature })\n```\n\nThe wrapper auto-registers `createFilteredRowModel` when the feature is\npresent.\n\n## Operators\n\nAll built-in operators:\n\n| Operator | Applies to | Behaviour |\n| ------------- | ---------- | --------- |\n| `contains` | text | case-insensitive substring |\n| `equals` | text, num, date, bool | strict equality (numeric where possible) |\n| `startsWith` | text | case-insensitive prefix |\n| `greaterThan` | num, date | strict `>` |\n| `lessThan` | num, date | strict `<` |\n| `between` | num, date | inclusive range - requires `valueTo` |\n| `isBlank` | any | empty / null / undefined / whitespace |\n\nThe set of operators offered per column depends on `editorType`:\n\n| `editorType` | operators |\n| ------------ | --------- |\n| `'text'` (default) | contains, equals, startsWith, isBlank |\n| `'number'` | equals, greaterThan, lessThan, isBlank |\n| `'date'` / `'datetime'` | equals, lessThan, greaterThan, isBlank |\n| `'checkbox'` | equals, isBlank |\n\n## Built-in `filterFns`\n\nFor programmatic filtering (without the menu), pass a `filterFn` on the\ncolumn or use the headless `createFilteredRowModel` directly.\n\n```ts\nimport { filterFns } from '@svgrid/grid'\n\nfilterFns.includesString(cellValue, query)\nfilterFns.equals(cellValue, query)\n```\n\n## See also\n\n- [Text filter](./text-filter.md)\n- [Number filter](./number-filter.md)\n- [Date filter](./date-filter.md)\n- [Set filter](./set-filter.md)\n- [Filter API](./filter-api.md)\n- [demos/03-excel-filters.svelte](../../../examples/src/demos/03-excel-filters.svelte)\n\n## Frequently asked questions\n\n### How do I filter a column in SvGrid?\n\nClick a column header's filter icon to open the operator + value popover. Text\ncolumns get `contains` / `equals` / `startsWith` / `isBlank`; number and date\ncolumns add `greaterThan` / `lessThan` / `between`. Filtering is on by default\nonce the filtering feature is registered.\n\n### Does SvGrid have Excel-style set filters?\n\nYes. A set filter shows a checklist of a column's distinct values so users can\ninclude \"active OR pending\" with checkboxes. Numeric and date columns\nrange-bucket their values so the list stays usable on large datasets.\n\n### Can I filter on the server?\n\nYes. Set `externalFilter` so the grid records filter state but your API does the\nfiltering. The grid emits the consolidated filter payload via `onFiltersChange`\nfor you to forward to the server.\n"
3395
+ "markdown": "# Filtering - overview\n\nClick any column header's filter icon to open the operator + value\npopover; numeric and date columns range-bucket their distinct values\nso the menu stays usable on big datasets:\n\n![Per-column filters and the quick filter collapse into one filterModel that the engine or your ServerDataSource applies to produce the filtered rows.](/docs-media/grid-filter-model.svg)\n\n<div data-docs-demo=\"03-excel-filters\" data-height=\"460\"></div>\n\nSvGrid offers four filtering surfaces. You opt into the one(s) you need\nthrough the `filterMode` prop on `<SvGrid>`:\n\n| `filterMode` | What it shows |\n| ------------ | ------------- |\n| `'menu'` (default) | A \"filter icon\" in each header opens a per-column operator + value popover. |\n| `'row'` | A filter row under the header - one input per column. |\n| `'global'` | A single search box above the grid that searches all visible columns. |\n| `'none'` | No filter UI. Drive filters programmatically only. |\n\n```svelte\n<SvGrid {data} {columns} features={features} filterMode=\"row\" />\n```\n\nPer-surface props (`showColumnFilters`, `showFilterRow`, `showGlobalFilter`)\noverride `filterMode` when set explicitly - useful when you want two\nsurfaces simultaneously.\n\n## Feature registration\n\nFiltering is gated by `columnFilteringFeature` plus\n`createFilteredRowModel`. Both must be registered for the column filter UI\nto actually filter rows:\n\n```ts\nimport {\n tableFeatures, columnFilteringFeature, createFilteredRowModel,\n} from '@svgrid/grid'\n\nconst features = tableFeatures({ columnFilteringFeature })\n```\n\nThe wrapper auto-registers `createFilteredRowModel` when the feature is\npresent.\n\n## Operators\n\nAll built-in operators:\n\n| Operator | Applies to | Behaviour |\n| ------------- | ---------- | --------- |\n| `contains` | text | case-insensitive substring |\n| `equals` | text, num, date, bool | strict equality (numeric where possible) |\n| `startsWith` | text | case-insensitive prefix |\n| `greaterThan` | num, date | strict `>` |\n| `lessThan` | num, date | strict `<` |\n| `between` | num, date | inclusive range - requires `valueTo` |\n| `isBlank` | any | empty / null / undefined / whitespace |\n\nThe set of operators offered per column depends on `editorType`:\n\n| `editorType` | operators |\n| ------------ | --------- |\n| `'text'` (default) | contains, equals, startsWith, isBlank |\n| `'number'` | equals, greaterThan, lessThan, isBlank |\n| `'date'` / `'datetime'` | equals, lessThan, greaterThan, isBlank |\n| `'checkbox'` | equals, isBlank |\n\n## Built-in `filterFns`\n\nFor programmatic filtering (without the menu), pass a `filterFn` on the\ncolumn or use the headless `createFilteredRowModel` directly.\n\n```ts\nimport { filterFns } from '@svgrid/grid'\n\nfilterFns.includesString(cellValue, query)\nfilterFns.equals(cellValue, query)\n```\n\n## See also\n\n- [Text filter](./text-filter.md)\n- [Number filter](./number-filter.md)\n- [Date filter](./date-filter.md)\n- [Set filter](./set-filter.md)\n- [Advanced filter](./advanced-filter.md) - OR across columns, nested groups, and comparisons against an aggregate\n- [Filter API](./filter-api.md)\n- [demos/03-excel-filters.svelte](../../../examples/src/demos/03-excel-filters.svelte)\n\n## Frequently asked questions\n\n### How do I filter a column in SvGrid?\n\nClick a column header's filter icon to open the operator + value popover. Text\ncolumns get `contains` / `equals` / `startsWith` / `isBlank`; number and date\ncolumns add `greaterThan` / `lessThan` / `between`. Filtering is on by default\nonce the filtering feature is registered.\n\n### Does SvGrid have Excel-style set filters?\n\nYes. A set filter shows a checklist of a column's distinct values so users can\ninclude \"active OR pending\" with checkboxes. Numeric and date columns\nrange-bucket their values so the list stays usable on large datasets.\n\n### Can I filter on the server?\n\nYes. Set `externalFilter` so the grid records filter state but your API does the\nfiltering. The grid emits the consolidated filter payload via `onFiltersChange`\nfor you to forward to the server.\n"
3378
3396
  },
3379
3397
  {
3380
3398
  "slug": "help/filtering/set-filter",
@@ -3392,13 +3410,13 @@ export const docs = [
3392
3410
  "slug": "help/glossary",
3393
3411
  "path": "docs/help/glossary.md",
3394
3412
  "title": "Glossary",
3395
- "markdown": "# Glossary\n\nTerminology used across the docs and the source. Sorted A-Z. If a term\nis unclear in a topic page and isn't on this list, please file an\nissue.\n\n## A\n\n**Accessor.** A function on a `ColumnDef` (`fieldFn`) that computes\na cell value from the row instead of reading a property by `field`.\nUsed heavily by pivot tables and computed columns.\n\n**Active cell.** The cell with focus. Tracked through every keyboard\nmove + click; exposed via `onActiveCellChange`. At most one cell is\nactive per grid. Has `tabindex=\"0\"`; every other cell has\n`tabindex=\"-1\"` (roving-tabindex pattern).\n\n**Aggregator.** A function that reduces a group's values to a single\ncell value (sum, avg, count, min, max, custom). Used by\n`columnGroupingFeature` and the pivot engine.\n\n**API (`SvGridApi`).** The imperative interface exposed via\n`<SvGrid onApiReady>`. Methods like `setSort`, `setFilter`, `addRow`,\n`getDisplayedRows`. See [API reference](./api-reference.md).\n\n## C\n\n**Cell context.** The `ctx` object passed to `cell`, `editable`, and\n`formatter` callbacks. Contains `cell`, `row`, `column`, `table`,\n`getValue`. Used inside custom cell snippets to access surrounding\nstate.\n\n**Column definition (`ColumnDef`).** The plain object that describes\none column: how to read its value (`field` / `fieldFn`), how to\nrender it (`cell`, `header`), and which features apply (`editable`,\n`format`, `editorType`). See [Column definitions](./columns/column-definitions.md).\n\n**Column group.** A `ColumnDef` whose `columns` array contains child\ncolumn defs. The grid emits one header row per nesting depth with\nproper `colSpan`. See [Column groups](./columns/column-groups.md).\n\n**Controlled vs uncontrolled state.** *Controlled*: the consumer owns\nthe state (a `$state` in your component) and listens to change events.\n*Uncontrolled*: the engine owns the state internally. Most grid state\nis uncontrollable-by-default; opt in via the `onXxxChange` props.\n\n## D\n\n**Density.** The vertical compaction of rows. Controlled via the\n`--sg-row-height` CSS variable + the `rowHeight` prop. Default is\n36 px (\"comfortable\"); 28 px is \"compact\"; 48 px is \"loose\".\n\n**Display rows.** The rows the grid is currently showing AFTER the\npipeline runs (filter -> sort -> group -> page). Accessible via\n`api.getDisplayedRows()`. NOT the same as the raw `data` prop.\n\n## E\n\n**Editor type.** A string on the `ColumnDef` that picks which built-in\neditor the grid uses when a user edits the cell. Values:\n`'text'` / `'number'` / `'date'` / `'datetime'` / `'checkbox'` /\n`'list'` / `'chips'`. A column without `editorType` is read-only even\nwhen `enableInlineEditing` is true.\n\n**Engine.** Layer 2 in the [architecture](./architecture.md). The\npure-function row-and-column model pipeline. Lives in\n`packages/grid/src/core.ts` + the `row-models/` folder.\n\n## F\n\n**Feature.** A bundle of row-model + state + behaviour you register\nvia `tableFeatures({...})`. Examples: `rowSortingFeature`,\n`columnFilteringFeature`. Features compose - registering five of them\nis normal.\n\n**Field.** The row property a column reads + writes by default. A\nshortcut for `fieldFn: (row) => row[field]`. When you can use\n`field`, prefer it - the engine has a fast path for property-keyed\ncolumns.\n\n**Filter mode.** A single prop on `<SvGrid>` that picks which filter\nUI the grid renders: `'menu'` (icon in each header), `'row'` (input\nunder each header), `'global'` (one search box), or `'none'`.\n\n**Format / formatter.** `format` is a declarative config (`{ type:\n'currency', currency: 'USD' }`) that the grid hands to `Intl`.\n`formatter` is a free-form callback that returns a string. Use\n`format` for standard types; `formatter` for custom output.\n\n## G\n\n**Group by.** A list of column ids whose unique values become rollup\ngroup rows. Set via `api.setGroupBy([...])` or the column menu's \"Group\nby this column\" entry. Drives `columnGroupingFeature`.\n\n## H\n\n**Header context.** The `ctx` object passed to `header` and `footer`\ntemplates. Contains `header`, `column`, `table`. Used inside custom\nheader snippets.\n\n**Headless.** Layer 2 without the renderer. `createSvGrid(...)`\nreturns a headless instance that emits the same row model + state but\ndoesn't paint anything. See [Why headless?](../why-headless.md).\n\n## I\n\n**Imperative API.** See *API (`SvGridApi`)*.\n\n**Inline editing.** Editing that happens inside the cell itself, vs\nin a side form. Toggled via `enableInlineEditing` on `<SvGrid>` +\n`editorType` on each column.\n\n## L\n\n**Layer 1, 2, 3.** See [Architecture overview](./architecture.md).\nLayer 1 is your data; Layer 2 is the engine; Layer 3 is the\n`<SvGrid>` renderer.\n\n**License key.** A string starting with `SVENTERPRISE-` set via\n`setLicenseKey(...)`. Removes the unlicensed watermark + console\nnudge. See [API stability](./api-stability.md) for license-related\nerrors.\n\n## P\n\n**Pinned column.** A column sticky-positioned to the left or right\nedge while the rest scroll. Set via the column menu or\n`api.pinColumn(id, 'left' | 'right' | null)`.\n\n**Pipeline.** The chain of row-model transformations: core ->\nfiltered -> sorted -> grouped -> expanded -> paginated. Runs once per\nstate change, NOT per scroll frame.\n\n**Pivot.** A reshape of facts into a grid of intersections - row\ndimensions cross column dimensions, with measures (aggregated values)\nat each intersection. SvGrid doesn't have a `pivot` prop; the pivot\nengine in [pivot.md](./pivot.md) builds the pivoted data + nested\nColumnDef tree the renderer needs.\n\n**Placeholder row.** A frozen \"stand-in\" row used by sparse infinite\nscroll. Renders skeleton cells until the real row loads. See\n[Server-side data](./server-side-data.md).\n\n**Enterprise.** The paid `@svgrid/enterprise` companion package adding export,\nimport, print, pivot helpers, and the AI assistant. Installed\nseparately; activated by `installEnterprise(api)`.\n\n## R\n\n**Render template.** A `cell` / `header` / `footer` value that\nreturns a Svelte snippet via `renderSnippet(SnippetRef, props)`. The\ngrid mounts the snippet inside the cell.\n\n**Roving tabindex.** The pattern of giving only the active cell\n`tabindex=\"0\"` while every other cell has `tabindex=\"-1\"`. Puts the\ngrid in the tab order exactly once. See\n[Accessibility](./accessibility.md).\n\n**Row data.** The `data` prop. Anything assignable to `RowData[]`\n(which is `Record<string, unknown>[]`). Plain objects; the grid never\nintrospects beyond `field` reads.\n\n**Row model.** A pipeline stage that takes rows in and emits rows\nout. Examples: `createSortedRowModel`, `createFilteredRowModel`. Pipe\nthem in via `tableFeatures(...)`. See [Architecture](./architecture.md).\n\n## S\n\n**Selection range.** A rectangular cell selection (anchor + focus\ncells), enabled by `enableCellSelection={true}`. Copy + paste (TSV)\nand the fill handle both operate on this range.\n\n**Snippet.** A Svelte 5 `{#snippet Foo(props)}` block. Used by the\ngrid as the render template format for cells, headers, and editors.\n`renderSnippet(Foo, props)` is the wrapper the column passes to\n`cell`.\n\n**Soft-gate.** The unlicensed Enterprise behaviour: features still run; a\nsmall watermark + one-time console message appear. Removed by\n`setLicenseKey('SVENTERPRISE-...')`.\n\n## T\n\n**Table features.** The bag returned by `tableFeatures({...})` -\na typed object identifying which row models + state slices the grid\nshould enable. Pass to `<SvGrid features={...}>` and use as the first\ngeneric of `ColumnDef<...>`.\n\n**Tree row.** A row with a `depth` field + `childIds` (or\nequivalent). Renders with indented chevron + connector lines. The grid\ndoesn't have a `treeData` prop; you derive `visibleRows` from\n`allRows` + `expanded`. See [Tree rows](./rows/tree-rows.md).\n\n## V\n\n**Virtualization.** Rendering only the rows + columns currently in\nview + a small overscan buffer. Enabled by default for any grid with\nmore than a few hundred rows. Bypassed in jsdom (zero layout metrics)\n- test against Playwright for virtualization behaviour.\n\n**Visible rows.** Same as *Display rows*.\n\n## W\n\n**Watermark.** The \"Unlicensed @svgrid/enterprise\" badge that appears\nbottom-right when a Enterprise feature runs without a valid license. Removed\nby `setLicenseKey('SVENTERPRISE-...')` or `dismissUnlicensedNudge()` (the\nnudge only).\n\n## See also\n\n- [Architecture overview](./architecture.md) - where each piece\n above sits.\n- [API reference](./api-reference.md) - every export with its tier\n badge.\n"
3413
+ "markdown": "# Glossary\n\nTerminology used across the docs and the source. Sorted A-Z. If a term\nis unclear in a topic page and isn't on this list, please file an\nissue.\n\n## A\n\n**Accessor.** A function on a `ColumnDef` (`fieldFn`) that computes\na cell value from the row instead of reading a property by `field`.\nUsed heavily by pivot tables and computed columns.\n\n**Active cell.** The cell with focus. Tracked through every keyboard\nmove + click; exposed via `onActiveCellChange`. At most one cell is\nactive per grid. Has `tabindex=\"0\"`; every other cell has\n`tabindex=\"-1\"` (roving-tabindex pattern).\n\n**Aggregator.** A function that reduces a group's values to a single\ncell value (sum, avg, count, min, max, custom). Used by\n`columnGroupingFeature` and the pivot engine.\n\n**API (`SvGridApi`).** The imperative interface exposed via\n`<SvGrid onApiReady>`. Methods like `setSort`, `setFilter`, `addRow`,\n`getDisplayedRows`. See [API reference](./api-reference.md).\n\n## C\n\n**Cell context.** The `ctx` object passed to `cell`, `editable`, and\n`formatter` callbacks. Contains `cell`, `row`, `column`, `table`,\n`getValue`. Used inside custom cell snippets to access surrounding\nstate.\n\n**Column definition (`ColumnDef`).** The plain object that describes\none column: how to read its value (`field` / `fieldFn`), how to\nrender it (`cell`, `header`), and which features apply (`editable`,\n`format`, `editorType`). See [Column definitions](./columns/column-definitions.md).\n\n**Column group.** A `ColumnDef` whose `columns` array contains child\ncolumn defs. The grid emits one header row per nesting depth with\nproper `colSpan`. See [Column groups](./columns/column-groups.md).\n\n**Controlled vs uncontrolled state.** *Controlled*: the consumer owns\nthe state (a `$state` in your component) and listens to change events.\n*Uncontrolled*: the engine owns the state internally. Most grid state\nis uncontrollable-by-default; opt in via the `onXxxChange` props.\n\n## D\n\n**Density.** The vertical compaction of rows. Controlled by the\n`rowHeight` prop (not a CSS token - the virtualizer needs the height as\na number). Default is 30 px; 28 px is \"compact\", 48 px is \"loose\".\n\n**Display rows.** The rows the grid is currently showing AFTER the\npipeline runs (filter -> sort -> group -> page). Accessible via\n`api.getDisplayedRows()`. NOT the same as the raw `data` prop.\n\n## E\n\n**Editor type.** A string on the `ColumnDef` that picks which built-in\neditor the grid uses when a user edits the cell. Values:\n`'text'` / `'number'` / `'date'` / `'datetime'` / `'checkbox'` /\n`'list'` / `'chips'`. A column without `editorType` is read-only even\nwhen `enableInlineEditing` is true.\n\n**Engine.** Layer 2 in the [architecture](./architecture.md). The\npure-function row-and-column model pipeline. Lives in\n`packages/grid/src/core.ts` + the `row-models/` folder.\n\n## F\n\n**Feature.** A bundle of row-model + state + behaviour you register\nvia `tableFeatures({...})`. Examples: `rowSortingFeature`,\n`columnFilteringFeature`. Features compose - registering five of them\nis normal.\n\n**Field.** The row property a column reads + writes by default. A\nshortcut for `fieldFn: (row) => row[field]`. When you can use\n`field`, prefer it - the engine has a fast path for property-keyed\ncolumns.\n\n**Filter mode.** A single prop on `<SvGrid>` that picks which filter\nUI the grid renders: `'menu'` (icon in each header), `'row'` (input\nunder each header), `'global'` (one search box), or `'none'`.\n\n**Format / formatter.** `format` is a declarative config (`{ type:\n'currency', currency: 'USD' }`) that the grid hands to `Intl`.\n`formatter` is a free-form callback that returns a string. Use\n`format` for standard types; `formatter` for custom output.\n\n## G\n\n**Group by.** A list of column ids whose unique values become rollup\ngroup rows. Set via `api.setGroupBy([...])` or the column menu's \"Group\nby this column\" entry. Drives `columnGroupingFeature`.\n\n## H\n\n**Header context.** The `ctx` object passed to `header` and `footer`\ntemplates. Contains `header`, `column`, `table`. Used inside custom\nheader snippets.\n\n**Headless.** Layer 2 without the renderer. `createSvGrid(...)`\nreturns a headless instance that emits the same row model + state but\ndoesn't paint anything. See [Why headless?](../why-headless.md).\n\n## I\n\n**Imperative API.** See *API (`SvGridApi`)*.\n\n**Inline editing.** Editing that happens inside the cell itself, vs\nin a side form. Toggled via `enableInlineEditing` on `<SvGrid>` +\n`editorType` on each column.\n\n## L\n\n**Layer 1, 2, 3.** See [Architecture overview](./architecture.md).\nLayer 1 is your data; Layer 2 is the engine; Layer 3 is the\n`<SvGrid>` renderer.\n\n**License key.** A string starting with `SVENTERPRISE-` set via\n`setLicenseKey(...)`. Removes the unlicensed watermark + console\nnudge. See [API stability](./api-stability.md) for license-related\nerrors.\n\n## P\n\n**Pinned column.** A column sticky-positioned to the left or right\nedge while the rest scroll. Set via the column menu, the\n`initialColumnPinning` prop, or\n`api.setColumnPinning({ left: [...], right: [...] })`.\n\n**Pipeline.** The chain of row-model transformations: core ->\nfiltered -> sorted -> grouped -> expanded -> paginated. Runs once per\nstate change, NOT per scroll frame.\n\n**Pivot.** A reshape of facts into a grid of intersections - row\ndimensions cross column dimensions, with measures (aggregated values)\nat each intersection. SvGrid doesn't have a `pivot` prop; the pivot\nengine in [pivot.md](./pivot.md) builds the pivoted data + nested\nColumnDef tree the renderer needs.\n\n**Placeholder row.** A frozen \"stand-in\" row used by sparse infinite\nscroll. Renders skeleton cells until the real row loads. See\n[Server-side data](./server-side-data.md).\n\n**Enterprise.** The paid `@svgrid/enterprise` companion package adding export,\nimport, print, pivot helpers, and the AI assistant. Installed\nseparately; activated by `installEnterprise(api)`.\n\n## R\n\n**Render template.** A `cell` / `header` / `footer` value that\nreturns a Svelte snippet via `renderSnippet(SnippetRef, props)`. The\ngrid mounts the snippet inside the cell.\n\n**Roving tabindex.** The pattern of giving only the active cell\n`tabindex=\"0\"` while every other cell has `tabindex=\"-1\"`. Puts the\ngrid in the tab order exactly once. See\n[Accessibility](./accessibility.md).\n\n**Row data.** The `data` prop. Anything assignable to `RowData[]`\n(which is `Record<string, unknown>[]`). Plain objects; the grid never\nintrospects beyond `field` reads.\n\n**Row model.** A pipeline stage that takes rows in and emits rows\nout. Examples: `createSortedRowModel`, `createFilteredRowModel`. Pipe\nthem in via `tableFeatures(...)`. See [Architecture](./architecture.md).\n\n## S\n\n**Selection range.** A rectangular cell selection (anchor + focus\ncells), enabled by `enableCellSelection={true}`. Copy + paste (TSV)\nand the fill handle both operate on this range.\n\n**Snippet.** A Svelte 5 `{#snippet Foo(props)}` block. Used by the\ngrid as the render template format for cells, headers, and editors.\n`renderSnippet(Foo, props)` is the wrapper the column passes to\n`cell`.\n\n**Soft-gate.** The unlicensed Enterprise behaviour: features still run; a\nsmall watermark + one-time console message appear. Removed by\n`setLicenseKey('SVENTERPRISE-...')`.\n\n## T\n\n**Table features.** The bag returned by `tableFeatures({...})` -\na typed object identifying which row models + state slices the grid\nshould enable. Pass to `<SvGrid features={...}>` and use as the first\ngeneric of `ColumnDef<...>`.\n\n**Tree row.** A row with a `depth` field + `childIds` (or\nequivalent). Renders with indented chevron + connector lines. The grid\ndoesn't have a `treeData` prop; you derive `visibleRows` from\n`allRows` + `expanded`. See [Tree rows](./rows/tree-rows.md).\n\n## V\n\n**Virtualization.** Rendering only the rows + columns currently in\nview + a small overscan buffer. Enabled by default for any grid with\nmore than a few hundred rows. Bypassed in jsdom (zero layout metrics)\n- test against Playwright for virtualization behaviour.\n\n**Visible rows.** Same as *Display rows*.\n\n## W\n\n**Watermark.** The \"Unlicensed @svgrid/enterprise\" badge that appears\nbottom-right when a Enterprise feature runs without a valid license. Removed\nby `setLicenseKey('SVENTERPRISE-...')` or `dismissUnlicensedNudge()` (the\nnudge only).\n\n## See also\n\n- [Architecture overview](./architecture.md) - where each piece\n above sits.\n- [API reference](./api-reference.md) - every export with its tier\n badge.\n"
3396
3414
  },
3397
3415
  {
3398
3416
  "slug": "help/grouping-aggregation",
3399
3417
  "path": "docs/help/grouping-aggregation.md",
3400
3418
  "title": "Grouping & aggregation",
3401
- "markdown": "# Grouping & aggregation\r\n\r\nRoll rows up by one or more columns and compute aggregates (sum, avg,\r\ncount, min, max, custom) at each group level. Powered by\r\n`columnGroupingFeature` plus per-column `aggregator` config.\r\n\r\n![Flat rows are grouped by one or more fields, aggregated with sum, average or count, then optionally pivoted across rows and columns.](/docs-media/grid-grouping-pivot.svg)\r\n\r\nTry it: drag a column into the group-by lane, then change aggregators\r\nper column:\r\n\r\n<div data-docs-demo=\"07-grouping-aggregation\" data-height=\"500\"></div>\r\n\r\n## Minimal example\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import {\r\n SvGrid, tableFeatures, rowSortingFeature, columnGroupingFeature,\r\n type ColumnDef,\r\n } from '@svgrid/grid'\r\n\r\n type Employee = {\r\n id: number; name: string; department: string;\r\n salary: number; performance: number\r\n }\r\n\r\n const features = tableFeatures({ rowSortingFeature, columnGroupingFeature })\r\n\r\n const columns: ColumnDef<typeof features, Employee>[] = [\r\n { field: 'department', header: 'Department' },\r\n { field: 'name', header: 'Name' },\r\n { field: 'salary', header: 'Salary',\r\n aggregate: 'sum',\r\n format: { type: 'currency', currency: 'USD' } },\r\n { field: 'performance', header: 'Performance',\r\n aggregate: 'avg' },\r\n ]\r\n\r\n const rows: Employee[] = [\r\n { id: 1, name: 'Ada', department: 'Engineering', salary: 180_000, performance: 4.8 },\r\n { id: 2, name: 'Linus', department: 'Engineering', salary: 195_000, performance: 4.6 },\r\n { id: 3, name: 'Grace', department: 'Operations', salary: 165_000, performance: 4.9 },\r\n ]\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n groupBy={['department']}\r\n/>\r\n```\r\n\r\nThe grid emits one group row per unique department value, with the\r\ngroup cell showing the rolled-up salary sum + average performance.\r\nClick the chevron on a group row to expand its children.\r\n\r\n## Setting the group-by\r\n\r\nThree ways, ranked by ergonomic order:\r\n\r\n1. **The column menu.** When the user opens a header's menu, \"Group\r\n by this column\" toggles that column in/out of the group-by list.\r\n2. **The `groupBy` prop.** Initial state for the group-by list.\r\n3. **The imperative API.** `api.setGroupBy(['department', 'role'])`\r\n for toolbars / saved views.\r\n\r\nThe group order is significant - `['region', 'country']` rolls up\r\ncountry inside region; reverse the array to flip the hierarchy.\r\n\r\n## Built-in aggregators\r\n\r\n| Aggregator | Returns | Behaviour on empty groups |\r\n| ---------- | ---------------------------------------------------- | ------------------------- |\r\n| `'sum'` | Sum of numeric cell values | `0` |\r\n| `'avg'` | Arithmetic mean (with safe divide-by-zero) | `null` |\r\n| `'count'` | Number of leaf rows | `0` |\r\n| `'min'` | Smallest value (numeric or `Intl.Collator`-comparable) | `null` |\r\n| `'max'` | Largest value | `null` |\r\n\r\n`'sum'` / `'avg'` / `'min'` / `'max'` cast values to `Number`. If the\r\ncolumn has non-numeric values mixed in, those rows are skipped.\r\n\r\n## Custom aggregator\r\n\r\nPass a function instead of a string for any group-aware computation:\r\n\r\n```ts\r\n{\r\n field: 'orders',\r\n header: 'Top customer',\r\n aggregate: (rows) => {\r\n const top = rows.reduce<Employee | null>(\r\n (acc, r) => !acc || r.orders > acc.orders ? r : acc,\r\n null,\r\n )\r\n return top?.name ?? '-'\r\n },\r\n}\r\n```\r\n\r\nThe callback gets every leaf row in the group (already filtered).\r\nReturn whatever the cell should display - string, number, or a\r\nformatted value.\r\n\r\n## Custom group cell rendering\r\n\r\nBy default the group cell shows `key (n)` - e.g. \"Engineering (12)\".\r\nOverride via the column's `cell` template:\r\n\r\n```svelte\r\n{#snippet GroupCell(props: { row: GroupRow<Employee> })}\r\n <span class=\"font-semibold\">\r\n {props.row.groupKey}\r\n <span class=\"text-sm opacity-60\">({props.row.subRows.length} reports)</span>\r\n </span>\r\n{/snippet}\r\n```\r\n\r\nThe `row.groupKey` is the unique group value (the department name in\r\nthe example). `row.subRows` is the children. `row.depth` is the\r\nnesting level (useful for indentation when you group by multiple\r\ncolumns).\r\n\r\n## Aggregating string columns\r\n\r\nStrings work with `'count'`, `'min'`, `'max'`, and any custom\r\naggregator. For sum / avg you'll get `NaN` because the cast to\r\n`Number` fails - the grid renders this as `-` by default.\r\n\r\nA useful custom aggregator for strings:\r\n\r\n```ts\r\n{\r\n field: 'tags',\r\n aggregate: (rows) => {\r\n const set = new Set<string>()\r\n for (const r of rows) for (const t of r.tags) set.add(t)\r\n return Array.from(set).join(', ')\r\n },\r\n}\r\n```\r\n\r\n## Performance\r\n\r\nAggregation runs once per group-by change, NOT per scroll frame. The\r\ncost is O(n) for `count` / `sum` / `avg`, O(n log n) for `min` / `max`\r\nbecause the engine sorts to find the extreme.\r\n\r\nFor a 100k-row dataset grouped by two columns with three aggregators,\r\nthe pipeline adds ~36 ms to the initial paint (see\r\n[Performance benchmarks](./benchmarks.md)). After that, scroll is\r\nunaffected - the renderer hands each visible group its precomputed\r\nvalue.\r\n\r\n## Group expansion state\r\n\r\n`expanded` is owned by the engine by default; you can hoist it for\r\nsaved-views purposes:\r\n\r\n```svelte\r\n<SvGrid\r\n ...\r\n expanded={controlledExpanded}\r\n onExpandedChange={(next) => (controlledExpanded = next)}\r\n/>\r\n```\r\n\r\nThe shape is `Record<groupId, boolean>` where `groupId` is the path\r\nthrough the hierarchy (`'Engineering > Senior'`).\r\n\r\n## Group sort vs leaf sort\r\n\r\nThe sort UI sorts within the active sort scope:\r\n\r\n- When grouping is OFF, sort applies to all rows.\r\n- When grouping is ON, sort applies WITHIN each group - groups stay\r\n in alphabetical (or group-aggregator) order; only the leaves inside\r\n each group reorder.\r\n\r\nTo sort the groups themselves by their rolled-up value, set the sort\r\non the aggregated column AFTER setting the group-by. The grid\r\nrecognises that the column is aggregated and sorts the group rows\r\ninstead of the leaves.\r\n\r\n## Filtering vs grouping\r\n\r\nFilters run BEFORE grouping (see [Architecture](./architecture.md) for\r\nthe pipeline order). The aggregator only sees rows that passed the\r\nfilter. This is what makes \"department salary sum, filtered to active\r\nemployees only\" work without any extra config.\r\n\r\n## Pivot vs group-by\r\n\r\nWhen the question is \"group by row dimensions, also group by column\r\ndimensions, also pick aggregators per measure\" - that's a pivot. The\r\n[pivot helpers](./pivot.md) build a different data structure\r\noptimised for that shape. Use group-by when you only roll up rows;\r\nuse pivot when you also roll up columns.\r\n\r\n## See also\r\n\r\n- [Architecture overview](./architecture.md) - where grouping sits in\r\n the pipeline.\r\n- [Pivot tables](./pivot.md) - the column-axis version.\r\n- [Row pagination](./rows/row-pagination.md) - the paging stage runs\r\n AFTER grouping, so group rows count toward the page size.\r\n- [Demo #07 Grouping + aggregation](https://svgrid.com/demos/07-grouping-aggregation/)\r\n - the source for the example above.\r\n\r\n## Display modes\r\n\r\n`groupDisplayMode` decides where group state is drawn:\r\n\r\n| Mode | Result |\r\n| --- | --- |\r\n| `groupRows` (default) | A full-width banner row per group. Unchanged behaviour. |\r\n| `singleColumn` | One synthetic **Group** column holding every level, indented by depth. |\r\n| `multipleColumns` | One synthetic column per grouped field. |\r\n\r\n```svelte\r\n<SvGrid {data} {columns} groupable groupDisplayMode=\"singleColumn\" />\r\n```\r\n\r\nBoth column modes hide the grouped **source** columns, because their values\r\nmove into the auto column - showing both would just duplicate them. They also\r\nrender the group row as an ordinary row, which is the real reason to use them:\r\nits aggregate cells then line up under the columns they belong to instead of\r\nsitting in a full-width strip.\r\n\r\nTune the combined column with `autoGroupColumnHeader` (default `\"Group\"`) and\r\n`autoGroupColumnWidth` (default `220`). In `multipleColumns` each column takes\r\nits name from the source column's header.\r\n\r\n## Group footers (subtotal rows)\r\n\r\n`groupFooters` closes each group with a subtotal row:\r\n\r\n```svelte\r\n<SvGrid {data} {columns} groupable groupFooters />\r\n```\r\n\r\nThe footer is a clone of the group banner, so it already carries that group's\r\naggregates and renders through the normal cell path - each total lands under\r\nits own column instead of in a full-width strip. It is not expandable and has\r\nno expander.\r\n\r\nOnly columns with an `aggregate` produce a value, the same ones that populate\r\nthe banner.\r\n\r\n## Grand total row\r\n\r\n`grandTotalRow` appends a single totals row for the whole filtered set:\r\n\r\n```svelte\r\n<SvGrid {data} {columns} grandTotalRow />\r\n```\r\n\r\nIt is independent of `groupFooters` - use it on a flat grid for a bottom totals\r\nline, or together for subtotals *and* a total:\r\n\r\n```svelte\r\n<SvGrid {data} {columns} groupable groupFooters grandTotalRow />\r\n```\r\n\r\nThree things to know:\r\n\r\n- It aggregates the **leaf** rows, so turning grouping on does not double-count\r\n (the group banners already carry subtotals).\r\n- It follows the **filtered** set, not the raw data - filter the grid and the\r\n total moves with it.\r\n- With `pageable`, it is appended only on the **last** page, so a total never\r\n appears mid-dataset. The value still covers every row, not just that page.\r\n\r\nColumns without an `aggregate` render blank, and if no column declares one the\r\nrow is skipped entirely.\r\n\r\n## Grouping with pagination\r\n\r\n`pageSize` budgets **data** rows. Group banners and footers do not count\r\nagainst it:\r\n\r\n```svelte\r\n<SvGrid {data} {columns} groupable groupFooters pageable pageSize={10} />\r\n```\r\n\r\nA page holds `pageSize` real rows and reprints the banners those rows sit\r\nunder, so a group split across a page boundary is labelled on both pages - the\r\nway a spreadsheet repeats group headers across a page break. Footers are\r\ninserted after paging, so switching them on never pushes data onto the next\r\npage.\r\n\r\nA collapsed group is the visible unit and takes one page slot itself; an\r\nexpanded one is a header and takes none.\r\n\r\n## Frequently asked questions\r\n\r\n### How do I group rows in SvGrid?\r\n\r\nRegister `columnGroupingFeature` and group by one or more columns. Each group\r\nrenders a collapsible header row, and you attach an `aggregator` per column to\r\ncompute sum, avg, count, min, max, or a custom reducer at every group level.\r\n\r\n### What aggregation functions does SvGrid support?\r\n\r\nBuilt-in `sum`, `avg`, `count`, `min`, and `max`, plus custom aggregators -\r\nany function that reduces a group's rows to a single value. Aggregates compute\r\nat each group level and at the grand-total footer.\r\n\r\n### Is grouping the same as a pivot table?\r\n\r\nNo. Grouping rolls rows up along the row axis. A pivot table also spreads a\r\nfield across the column axis with nested headers - that is the `@svgrid/enterprise`\r\npivot model. See [Pivot tables](./pivot.md) for the column-axis version.\r\n"
3419
+ "markdown": "# Grouping & aggregation\r\n\r\nRoll rows up by one or more columns and compute aggregates (sum, avg,\r\ncount, min, max, custom) at each group level. Powered by\r\n`columnGroupingFeature` plus per-column `aggregator` config.\r\n\r\n![Flat rows are grouped by one or more fields, aggregated with sum, average or count, then optionally pivoted across rows and columns.](/docs-media/grid-grouping-pivot.svg)\r\n\r\nTry it: drag a column into the group-by lane, then change aggregators\r\nper column:\r\n\r\n<div data-docs-demo=\"07-grouping-aggregation\" data-height=\"500\"></div>\r\n\r\n## Minimal example\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import {\r\n SvGrid, tableFeatures, rowSortingFeature, columnGroupingFeature,\r\n type ColumnDef,\r\n } from '@svgrid/grid'\r\n\r\n type Employee = {\r\n id: number; name: string; department: string;\r\n salary: number; performance: number\r\n }\r\n\r\n const features = tableFeatures({ rowSortingFeature, columnGroupingFeature })\r\n\r\n const columns: ColumnDef<typeof features, Employee>[] = [\r\n { field: 'department', header: 'Department' },\r\n { field: 'name', header: 'Name' },\r\n { field: 'salary', header: 'Salary',\r\n aggregate: 'sum',\r\n format: { type: 'currency', currency: 'USD' } },\r\n { field: 'performance', header: 'Performance',\r\n aggregate: 'avg' },\r\n ]\r\n\r\n const rows: Employee[] = [\r\n { id: 1, name: 'Ada', department: 'Engineering', salary: 180_000, performance: 4.8 },\r\n { id: 2, name: 'Linus', department: 'Engineering', salary: 195_000, performance: 4.6 },\r\n { id: 3, name: 'Grace', department: 'Operations', salary: 165_000, performance: 4.9 },\r\n ]\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n groupBy={['department']}\r\n/>\r\n```\r\n\r\nThe grid emits one group row per unique department value, with the\r\ngroup cell showing the rolled-up salary sum + average performance.\r\nClick the chevron on a group row to expand its children.\r\n\r\n## Setting the group-by\r\n\r\nThree ways, ranked by ergonomic order:\r\n\r\n1. **The column menu.** When the user opens a header's menu, \"Group\r\n by this column\" toggles that column in/out of the group-by list.\r\n2. **The `groupBy` prop.** Seeds the group-by list and re-applies\r\n whenever the prop's own value changes, so it works both as initial\r\n state and as a controlled value. A group-by set from the menu or the\r\n API is not clobbered while the prop stays put.\r\n3. **The imperative API.** `api.setGroupBy(['department', 'role'])`\r\n for toolbars / saved views.\r\n\r\n`groupBy` is ignored when `treeData` is set - a row cannot be both a\r\nhierarchy node and bucketed under a group banner.\r\n\r\nThe group order is significant - `['region', 'country']` rolls up\r\ncountry inside region; reverse the array to flip the hierarchy.\r\n\r\n## Built-in aggregators\r\n\r\n| Aggregator | Returns | Behaviour on empty groups |\r\n| ---------- | ---------------------------------------------------- | ------------------------- |\r\n| `'sum'` | Sum of numeric cell values | `0` |\r\n| `'avg'` | Arithmetic mean (with safe divide-by-zero) | `null` |\r\n| `'count'` | Number of leaf rows | `0` |\r\n| `'min'` | Smallest value (numeric or `Intl.Collator`-comparable) | `null` |\r\n| `'max'` | Largest value | `null` |\r\n\r\n`'sum'` / `'avg'` / `'min'` / `'max'` cast values to `Number`. If the\r\ncolumn has non-numeric values mixed in, those rows are skipped.\r\n\r\n## Custom aggregator\r\n\r\nPass a function instead of a string for any group-aware computation:\r\n\r\n```ts\r\n{\r\n field: 'orders',\r\n header: 'Top customer',\r\n aggregate: (rows) => {\r\n const top = rows.reduce<Employee | null>(\r\n (acc, r) => !acc || r.orders > acc.orders ? r : acc,\r\n null,\r\n )\r\n return top?.name ?? '-'\r\n },\r\n}\r\n```\r\n\r\nThe callback gets every leaf row in the group (already filtered).\r\nReturn whatever the cell should display - string, number, or a\r\nformatted value.\r\n\r\n## Custom group cell rendering\r\n\r\nBy default the group cell shows `key (n)` - e.g. \"Engineering (12)\".\r\nOverride via the column's `cell` template:\r\n\r\n```svelte\r\n{#snippet GroupCell(props: { row: GroupRow<Employee> })}\r\n <span class=\"font-semibold\">\r\n {props.row.groupKey}\r\n <span class=\"text-sm opacity-60\">({props.row.subRows.length} reports)</span>\r\n </span>\r\n{/snippet}\r\n```\r\n\r\nThe `row.groupKey` is the unique group value (the department name in\r\nthe example). `row.subRows` is the children. `row.depth` is the\r\nnesting level (useful for indentation when you group by multiple\r\ncolumns).\r\n\r\n## Aggregating string columns\r\n\r\nStrings work with `'count'`, `'min'`, `'max'`, and any custom\r\naggregator. For sum / avg you'll get `NaN` because the cast to\r\n`Number` fails - the grid renders this as `-` by default.\r\n\r\nA useful custom aggregator for strings:\r\n\r\n```ts\r\n{\r\n field: 'tags',\r\n aggregate: (rows) => {\r\n const set = new Set<string>()\r\n for (const r of rows) for (const t of r.tags) set.add(t)\r\n return Array.from(set).join(', ')\r\n },\r\n}\r\n```\r\n\r\n## Performance\r\n\r\nAggregation runs once per group-by change, NOT per scroll frame. The\r\ncost is O(n) for `count` / `sum` / `avg`, O(n log n) for `min` / `max`\r\nbecause the engine sorts to find the extreme.\r\n\r\nFor a 100k-row dataset grouped by two columns with three aggregators,\r\nthe pipeline adds ~36 ms to the initial paint (see\r\n[Performance benchmarks](./benchmarks.md)). After that, scroll is\r\nunaffected - the renderer hands each visible group its precomputed\r\nvalue.\r\n\r\n## Group expansion state\r\n\r\n`expanded` is owned by the engine by default; you can hoist it for\r\nsaved-views purposes:\r\n\r\n```svelte\r\n<SvGrid\r\n ...\r\n expanded={controlledExpanded}\r\n onExpandedChange={(next) => (controlledExpanded = next)}\r\n/>\r\n```\r\n\r\n`onExpandedChange` fires for every path that changes expansion: a click\r\non a group banner, `api.setRowExpanded()`, and\r\n`api.expandAllGroups()` / `api.collapseAllGroups()`. It receives the\r\nfull next map, so writing it straight back into `expanded` (as above) is\r\nsafe and will not loop.\r\n\r\nThe shape is `Record<rowId, boolean>`. Group row ids are built from the\r\ngrouping path rather than the display label - grouping by `department`\r\ngives `group_department_Engineering`, and adding `role` beneath it gives\r\n`group_department_Engineering_role_Senior`. Tree rows key off the\r\nengine's row id instead, so set `getRowId` if you want those keys to be\r\nyour own ids. Capture the map from `onExpandedChange` rather than\r\nhand-building the keys.\r\n\r\n## Group sort vs leaf sort\r\n\r\nThe sort UI sorts within the active sort scope:\r\n\r\n- When grouping is OFF, sort applies to all rows.\r\n- When grouping is ON, sort applies WITHIN each group - groups stay\r\n in alphabetical (or group-aggregator) order; only the leaves inside\r\n each group reorder.\r\n\r\nTo sort the groups themselves by their rolled-up value, set the sort\r\non the aggregated column AFTER setting the group-by. The grid\r\nrecognises that the column is aggregated and sorts the group rows\r\ninstead of the leaves.\r\n\r\n## Filtering vs grouping\r\n\r\nFilters run BEFORE grouping (see [Architecture](./architecture.md) for\r\nthe pipeline order). The aggregator only sees rows that passed the\r\nfilter. This is what makes \"department salary sum, filtered to active\r\nemployees only\" work without any extra config.\r\n\r\n## Pivot vs group-by\r\n\r\nWhen the question is \"group by row dimensions, also group by column\r\ndimensions, also pick aggregators per measure\" - that's a pivot. The\r\n[pivot helpers](./pivot.md) build a different data structure\r\noptimised for that shape. Use group-by when you only roll up rows;\r\nuse pivot when you also roll up columns.\r\n\r\n## See also\r\n\r\n- [Architecture overview](./architecture.md) - where grouping sits in\r\n the pipeline.\r\n- [Pivot tables](./pivot.md) - the column-axis version.\r\n- [Row pagination](./rows/row-pagination.md) - the paging stage runs\r\n AFTER grouping, so group rows count toward the page size.\r\n- [Demo #07 Grouping + aggregation](https://svgrid.com/demos/07-grouping-aggregation/)\r\n - the source for the example above.\r\n\r\n## Display modes\r\n\r\n`groupDisplayMode` decides where group state is drawn:\r\n\r\n| Mode | Result |\r\n| --- | --- |\r\n| `groupRows` (default) | A full-width banner row per group. Unchanged behaviour. |\r\n| `singleColumn` | One synthetic **Group** column holding every level, indented by depth. |\r\n| `multipleColumns` | One synthetic column per grouped field. |\r\n\r\n```svelte\r\n<SvGrid {data} {columns} groupable groupDisplayMode=\"singleColumn\" />\r\n```\r\n\r\nBoth column modes hide the grouped **source** columns, because their values\r\nmove into the auto column - showing both would just duplicate them. They also\r\nrender the group row as an ordinary row, which is the real reason to use them:\r\nits aggregate cells then line up under the columns they belong to instead of\r\nsitting in a full-width strip.\r\n\r\nTune the combined column with `autoGroupColumnHeader` (default `\"Group\"`) and\r\n`autoGroupColumnWidth` (default `220`). In `multipleColumns` each column takes\r\nits name from the source column's header.\r\n\r\n## Group footers (subtotal rows)\r\n\r\n`groupFooters` closes each group with a subtotal row:\r\n\r\n```svelte\r\n<SvGrid {data} {columns} groupable groupFooters />\r\n```\r\n\r\nThe footer is a clone of the group banner, so it already carries that group's\r\naggregates and renders through the normal cell path - each total lands under\r\nits own column instead of in a full-width strip. It is not expandable and has\r\nno expander.\r\n\r\nOnly columns with an `aggregate` produce a value, the same ones that populate\r\nthe banner.\r\n\r\n## Grand total row\r\n\r\n`grandTotalRow` appends a single totals row for the whole filtered set:\r\n\r\n```svelte\r\n<SvGrid {data} {columns} grandTotalRow />\r\n```\r\n\r\nIt is independent of `groupFooters` - use it on a flat grid for a bottom totals\r\nline, or together for subtotals *and* a total:\r\n\r\n```svelte\r\n<SvGrid {data} {columns} groupable groupFooters grandTotalRow />\r\n```\r\n\r\nThree things to know:\r\n\r\n- It aggregates the **leaf** rows, so turning grouping on does not double-count\r\n (the group banners already carry subtotals).\r\n- It follows the **filtered** set, not the raw data - filter the grid and the\r\n total moves with it.\r\n- With `pageable`, it is appended only on the **last** page, so a total never\r\n appears mid-dataset. The value still covers every row, not just that page.\r\n\r\nColumns without an `aggregate` render blank, and if no column declares one the\r\nrow is skipped entirely.\r\n\r\n## Grouping with pagination\r\n\r\n`pageSize` budgets **data** rows. Group banners and footers do not count\r\nagainst it:\r\n\r\n```svelte\r\n<SvGrid {data} {columns} groupable groupFooters pageable pageSize={10} />\r\n```\r\n\r\nA page holds `pageSize` real rows and reprints the banners those rows sit\r\nunder, so a group split across a page boundary is labelled on both pages - the\r\nway a spreadsheet repeats group headers across a page break. Footers are\r\ninserted after paging, so switching them on never pushes data onto the next\r\npage.\r\n\r\nA collapsed group is the visible unit and takes one page slot itself; an\r\nexpanded one is a header and takes none.\r\n\r\n## Frequently asked questions\r\n\r\n### How do I group rows in SvGrid?\r\n\r\nRegister `columnGroupingFeature` and group by one or more columns. Each group\r\nrenders a collapsible header row, and you attach an `aggregate` per column to\r\ncompute sum, avg, count, min, max, or a custom reducer at every group level.\r\n\r\n### What aggregation functions does SvGrid support?\r\n\r\nBuilt-in `sum`, `avg`, `count`, `min`, and `max`, plus custom aggregators -\r\nany function that reduces a group's rows to a single value. Aggregates compute\r\nat each group level and at the grand-total footer.\r\n\r\n### Is grouping the same as a pivot table?\r\n\r\nNo. Grouping rolls rows up along the row axis. A pivot table also spreads a\r\nfield across the column axis with nested headers - that is the `@svgrid/enterprise`\r\npivot model. See [Pivot tables](./pivot.md) for the column-axis version.\r\n"
3402
3420
  },
3403
3421
  {
3404
3422
  "slug": "help/grouping/aggregators",
@@ -3470,19 +3488,19 @@ export const docs = [
3470
3488
  "slug": "help/llm-grounding",
3471
3489
  "path": "docs/help/llm-grounding.md",
3472
3490
  "title": "Use sv-grid docs as LLM context",
3473
- "markdown": "# Use sv-grid docs as LLM context\r\n\r\nThis page is the \"how do I make ChatGPT / Claude / Cursor write good\r\nsv-grid code?\" guide. Three pre-built artefacts ship with the docs\r\nspecifically so models can ground themselves in current, accurate\r\ninformation instead of hallucinating from training data.\r\n\r\n## The four files\r\n\r\n| File | Format | Size | Use for |\r\n| ---------------------------------------- | ---------- | ------ | ---------------------------------------------------------------------- |\r\n| [`/llms.txt`](/llms.txt) | Plain text | ~10 kB | First-pass context: the topic map with one-line summaries |\r\n| [`/llms-full.txt`](/llms-full.txt) | Plain text | ~700 kB | Deep grounding: every doc page concatenated |\r\n| [`/docs.json`](/docs.json) | JSON | ~80 kB | Programmatic crawling: section tree, per-page metadata, demo links |\r\n| [`/schemas/index.json`](/schemas/index.json) | JSON | ~30 kB | Validation: machine-checkable shape of `ColumnDef`, `<SvGrid>` props, export options |\r\n\r\nAll four are regenerated on every commit by `tools/build-docs-index.mjs`\r\nand `tools/build-schemas.mjs`. They live at the docs origin\r\n(`https://svgrid.com/...`) so you can fetch them at runtime.\r\n\r\n## Recipe 1: Drop into a custom GPT / Claude project\r\n\r\nThe simplest way. Both ChatGPT (custom GPTs) and Claude (projects)\r\nlet you upload reference files that ride along with every chat.\r\n\r\n1. Save [`/llms-full.txt`](/llms-full.txt) locally.\r\n2. In ChatGPT: *Create custom GPT → Configure → Knowledge → Upload files*.\r\n3. In Claude: *Project → Project knowledge → Add document*.\r\n4. Add this system instruction:\r\n\r\n```\r\nYou are a sv-grid expert. Ground every answer in the attached\r\nllms-full.txt. If a question references an API not in the document,\r\nsay so and ask the user to upgrade rather than inventing one. Prefer\r\nthe smallest working example. When showing columns, follow the\r\ncolumn-def.json schema exactly.\r\n```\r\n\r\n5. (Optional) Upload `column-def.json` and `svgrid-options.json`\r\n alongside so the model can self-check generated config.\r\n\r\nThat's it. The next time you ask \"how do I export only selected rows\r\nto xlsx?\" the model answers from the doc text, not from its\r\nyear-old training cutoff.\r\n\r\n## Recipe 2: Cursor / Continue / Cody rules file\r\n\r\nMost IDE assistants honour a `.cursorrules` / `.continuerules` /\r\n`.aider.conf.yml` file in the repo root. Drop in:\r\n\r\n```\r\n# .cursorrules\r\n\r\nWhen generating sv-grid code:\r\n- Read context from https://svgrid.com/llms.txt before answering.\r\n- For column definitions, generate against\r\n https://svgrid.com/schemas/column-def.json (Draft 2020-12 JSON Schema).\r\n- Use Svelte 5 runes ($state, $derived, $effect) - never legacy stores.\r\n- Use `editorType: 'list'` with `editorOptions` for dropdowns,\r\n not raw <select> elements.\r\n- Always type the grid as\r\n `SvGrid<typeof features, RowType>` so column inference works.\r\n- The two npm packages are `@svgrid/grid` (MIT) and `@svgrid/enterprise`\r\n (commercial). Never import from `@sv-grid/core` or `svelte-grid`,\r\n which are different projects.\r\n```\r\n\r\n## Recipe 3: Programmatic grounding in your own agent\r\n\r\nIf you're building a custom agent (OpenAI Agents SDK, Anthropic SDK,\r\nLangChain, custom), fetch the docs once at boot:\r\n\r\n```ts\r\nconst [topicMap, schemas] = await Promise.all([\r\n fetch('https://svgrid.com/llms.txt').then((r) => r.text()),\r\n fetch('https://svgrid.com/schemas/index.json').then((r) => r.json()),\r\n])\r\n\r\nconst systemPrompt = `You write Svelte 5 code that uses sv-grid.\r\n\r\nDOCS INDEX (use these URLs to look up specifics):\r\n${topicMap}\r\n\r\nSCHEMAS available for validation:\r\n${JSON.stringify(schemas, null, 2)}\r\n\r\nFor deep API questions, fetch https://svgrid.com/llms-full.txt or\r\nthe specific page from the index above.`\r\n```\r\n\r\nNow hand the model a tool that can fetch arbitrary `/docs.json` paths\r\non demand, and it can answer any sv-grid question with current data.\r\n\r\n## Recipe 4: MCP server (best for daily-driver chat)\r\n\r\nIf your workflow centers on Claude Desktop / Cursor / Zed, the\r\n[MCP server](./mcp-server.md) is the single line of config that\r\nexposes all four files PLUS callable tools (`scaffoldColumns`,\r\n`validateColumns`, `previewExport`). Skip Recipes 1-3 and use the\r\nMCP server instead.\r\n\r\n## What's IN the grounding files\r\n\r\nEvery file is exhaustive but tightly scoped to sv-grid surface area:\r\n\r\n- **API surface**: every prop on `<SvGrid>`, every method on\r\n `SvGridApi`, every field on `ColumnDef`\r\n- **Features**: when to use sorting / filtering / grouping / pagination\r\n feature toggles, and the trade-offs\r\n- **Enterprise tier**: export, import, pivot - each documented as\r\n if it were free, with the licensing call-out at the top of the page\r\n- **Recipes**: 25+ copy-paste patterns from the cookbook\r\n- **Migrations**: how to translate concepts from other data grids\r\n- **Errors**: every typed error the library throws, with the trigger\r\n and the fix\r\n\r\n## What's NOT in the grounding files\r\n\r\n- **Internal implementation**: virtualizer math, headless engine\r\n pipeline internals - not part of the public surface\r\n- **Future / roadmap**: deliberately excluded so the model never\r\n confuses ambition with reality\r\n- **CSS class hashes**: Svelte mangles class names. The\r\n `--sg-*` tokens are stable and documented; the class names are not.\r\n\r\n## Keeping the grounding fresh\r\n\r\nRe-fetch on every model turn for chat tools; cache for ~24h for\r\nagent loops. The docs are versioned - if you pin to a specific\r\nversion, append a `?v=1.6.0` query string when fetching from the\r\norigin (rejected if the major changes; we serve a 410).\r\n\r\n## See also\r\n\r\n- [MCP server](./mcp-server.md) - the easiest way to wire all this in\r\n- [Agents](./agents.md) - building an agent that DRIVES the grid (not just describes it)\r\n- [API stability](./api-stability.md) - what we promise to keep stable across versions\r\n"
3491
+ "markdown": "# Use sv-grid docs as LLM context\r\n\r\nThis page is the \"how do I make ChatGPT / Claude / Cursor write good\r\nsv-grid code?\" guide. Three pre-built artefacts ship with the docs\r\nspecifically so models can ground themselves in current, accurate\r\ninformation instead of hallucinating from training data.\r\n\r\n## The four files\r\n\r\n| File | Format | Size | Use for |\r\n| ---------------------------------------- | ---------- | ------ | ---------------------------------------------------------------------- |\r\n| [`/llms.txt`](/llms.txt) | Plain text | ~10 kB | First-pass context: the topic map with one-line summaries |\r\n| [`/llms-full.txt`](/llms-full.txt) | Plain text | ~700 kB | Deep grounding: every doc page concatenated |\r\n| [`/docs.json`](/docs.json) | JSON | ~80 kB | Programmatic crawling: section tree, per-page metadata, demo links |\r\n| [`/schemas/index.json`](/schemas/index.json) | JSON | ~30 kB | Validation: machine-checkable shape of `ColumnDef`, `<SvGrid>` props, export options |\r\n\r\nAll four are regenerated on every commit by `tools/build-docs-index.mjs`\r\nand `tools/build-schemas.mjs`. They live at the docs origin\r\n(`https://svgrid.com/...`) so you can fetch them at runtime.\r\n\r\n## Recipe 1: Drop into a custom GPT / Claude project\r\n\r\nThe simplest way. Both ChatGPT (custom GPTs) and Claude (projects)\r\nlet you upload reference files that ride along with every chat.\r\n\r\n1. Save [`/llms-full.txt`](/llms-full.txt) locally.\r\n2. In ChatGPT: *Create custom GPT → Configure → Knowledge → Upload files*.\r\n3. In Claude: *Project → Project knowledge → Add document*.\r\n4. Add this system instruction:\r\n\r\n```\r\nYou are a sv-grid expert. Ground every answer in the attached\r\nllms-full.txt. If a question references an API not in the document,\r\nsay so and ask the user to upgrade rather than inventing one. Prefer\r\nthe smallest working example. When showing columns, follow the\r\ncolumn-def.json schema exactly.\r\n```\r\n\r\n5. (Optional) Upload `column-def.json` and `svgrid-options.json`\r\n alongside so the model can self-check generated config.\r\n\r\nThat's it. The next time you ask \"how do I export only selected rows\r\nto xlsx?\" the model answers from the doc text, not from its\r\nyear-old training cutoff.\r\n\r\n## Recipe 2: Cursor / Continue / Cody rules file\r\n\r\nMost IDE assistants honour a `.cursorrules` / `.continuerules` /\r\n`.aider.conf.yml` file in the repo root. Drop in:\r\n\r\n```\r\n# .cursorrules\r\n\r\nWhen generating sv-grid code:\r\n- Read context from https://svgrid.com/llms.txt before answering.\r\n- For column definitions, generate against\r\n https://svgrid.com/schemas/column-def.json (Draft 2020-12 JSON Schema).\r\n- Use Svelte 5 runes ($state, $derived, $effect) - never legacy stores.\r\n- Use `editorType: 'list'` with `editorOptions` for dropdowns,\r\n not raw <select> elements.\r\n- Always type the grid as\r\n `SvGrid<typeof features, RowType>` so column inference works.\r\n- The two npm packages are `@svgrid/grid` (MIT) and `@svgrid/enterprise`\r\n (commercial). Never import from `@sv-grid/core` or `svelte-grid`,\r\n which are different projects.\r\n```\r\n\r\n## Recipe 3: Programmatic grounding in your own agent\r\n\r\nIf you're building a custom agent (OpenAI Agents SDK, Anthropic SDK,\r\nLangChain, custom), fetch the docs once at boot:\r\n\r\n```ts\r\nconst [topicMap, schemas] = await Promise.all([\r\n fetch('https://svgrid.com/llms.txt').then((r) => r.text()),\r\n fetch('https://svgrid.com/schemas/index.json').then((r) => r.json()),\r\n])\r\n\r\nconst systemPrompt = `You write Svelte 5 code that uses sv-grid.\r\n\r\nDOCS INDEX (use these URLs to look up specifics):\r\n${topicMap}\r\n\r\nSCHEMAS available for validation:\r\n${JSON.stringify(schemas, null, 2)}\r\n\r\nFor deep API questions, fetch https://svgrid.com/llms-full.txt or\r\nthe specific page from the index above.`\r\n```\r\n\r\nNow hand the model a tool that can fetch arbitrary `/docs.json` paths\r\non demand, and it can answer any sv-grid question with current data.\r\n\r\n## Recipe 4: MCP server (best for daily-driver chat)\r\n\r\nIf your workflow centers on Claude Desktop / Cursor / Zed, the\r\n[MCP server](./mcp-server.md) is the single line of config that\r\nexposes the same grounding PLUS callable tools (`search_docs`,\r\n`get_doc`, `get_example_source`, `get_api_reference`, and the SvGrid\r\nStudio generators). Skip Recipes 1-3 and use the MCP server instead.\r\n\r\n## What's IN the grounding files\r\n\r\nEvery file is exhaustive but tightly scoped to sv-grid surface area:\r\n\r\n- **API surface**: every prop on `<SvGrid>`, every method on\r\n `SvGridApi`, every field on `ColumnDef`\r\n- **Features**: when to use sorting / filtering / grouping / pagination\r\n feature toggles, and the trade-offs\r\n- **Enterprise tier**: export, import, pivot - each documented as\r\n if it were free, with the licensing call-out at the top of the page\r\n- **Recipes**: 25+ copy-paste patterns from the cookbook\r\n- **Migrations**: how to translate concepts from other data grids\r\n- **Errors**: every typed error the library throws, with the trigger\r\n and the fix\r\n\r\n## What's NOT in the grounding files\r\n\r\n- **Internal implementation**: virtualizer math, headless engine\r\n pipeline internals - not part of the public surface\r\n- **Future / roadmap**: deliberately excluded so the model never\r\n confuses ambition with reality\r\n- **CSS class hashes**: Svelte mangles class names. The\r\n `--sg-*` tokens are stable and documented; the class names are not.\r\n\r\n## Keeping the grounding fresh\r\n\r\nRe-fetch on every model turn for chat tools; cache for ~24h for\r\nagent loops. The docs are versioned - if you pin to a specific\r\nversion, append a `?v=1.6.0` query string when fetching from the\r\norigin (rejected if the major changes; we serve a 410).\r\n\r\n## See also\r\n\r\n- [MCP server](./mcp-server.md) - the easiest way to wire all this in\r\n- [Agents](./agents.md) - building an agent that DRIVES the grid (not just describes it)\r\n- [API stability](./api-stability.md) - what we promise to keep stable across versions\r\n"
3474
3492
  },
3475
3493
  {
3476
3494
  "slug": "help/mcp-server",
3477
3495
  "path": "docs/help/mcp-server.md",
3478
3496
  "title": "MCP server",
3479
- "markdown": "# MCP server\r\n\r\nThe sv-grid MCP server lets AI clients (Claude Desktop, Cursor, Zed,\r\nContinue, custom agents) query the documentation, scaffold column\r\ndefinitions, and preview exports - all grounded in the same schemas\r\nthe library ships with. No API key required; everything runs locally\r\nagainst your installed copy.\r\n\r\n![An AI coding agent calls the @svgrid/mcp server over the Model Context Protocol, which runs grid tools and returns structured JSON results back to the agent.](/docs-media/grid-mcp.svg)\r\n\r\n> **What is MCP?** Model Context Protocol is the open standard\r\n> ([modelcontextprotocol.io](https://modelcontextprotocol.io)) for\r\n> exposing tools / resources / prompts to LLM clients. sv-grid ships an\r\n> MCP server out of the box so the model your team already uses can\r\n> \"see\" the grid without you having to copy-paste docs into prompts.\r\n\r\n## Install\r\n\r\n```bash\r\n# Inside any project that already depends on @svgrid/grid\r\npnpm add -D @sv-grid/mcp-server\r\n```\r\n\r\nThe server is a Node binary. Run it on demand from the package's\r\n`bin` field - no daemon to maintain.\r\n\r\n## Wire it into your AI client\r\n\r\n### Claude Desktop\r\n\r\nEdit `~/Library/Application Support/Claude/claude_desktop_config.json`\r\n(macOS) or `%APPDATA%\\Claude\\claude_desktop_config.json` (Windows):\r\n\r\n```json\r\n{\r\n \"mcpServers\": {\r\n \"sv-grid\": {\r\n \"command\": \"npx\",\r\n \"args\": [\"-y\", \"@sv-grid/mcp-server\"]\r\n }\r\n }\r\n}\r\n```\r\n\r\nRestart Claude Desktop. Type `@sv-grid` in any chat to confirm the\r\ntools are exposed.\r\n\r\n### Cursor\r\n\r\n`Settings → MCP → Add new MCP server`:\r\n\r\n```json\r\n{ \"command\": \"npx\", \"args\": [\"-y\", \"@sv-grid/mcp-server\"] }\r\n```\r\n\r\n### Zed\r\n\r\n`~/.config/zed/settings.json`:\r\n\r\n```json\r\n{\r\n \"context_servers\": {\r\n \"sv-grid\": { \"command\": { \"path\": \"npx\", \"args\": [\"-y\", \"@sv-grid/mcp-server\"] } }\r\n }\r\n}\r\n```\r\n\r\n### Custom agents (OpenAI Agents SDK, Anthropic SDK, LangChain)\r\n\r\nPoint your client's MCP transport at:\r\n\r\n```\r\nnpx -y @sv-grid/mcp-server\r\n```\r\n\r\nAny client that speaks MCP stdio works.\r\n\r\n## Tools exposed\r\n\r\nThe server registers six tools. All return structured JSON; none\r\nrequire an API key or network access.\r\n\r\n### `searchDocs`\r\n\r\nGround the model in the doc set without dumping the whole corpus.\r\n\r\n```ts\r\nsearchDocs({ query: string, limit?: number }):\r\n Array<{ path, title, summary, score, snippet }>\r\n```\r\n\r\nBacked by the same `docs.json` manifest you can fetch directly.\r\n\r\n### `getDocPage`\r\n\r\nPull the full markdown of one page by URL or path.\r\n\r\n```ts\r\ngetDocPage({ path: '/help/pivot.md' }): { title, source, demoIds }\r\n```\r\n\r\n### `scaffoldColumns`\r\n\r\nGenerate a `ColumnDef[]` from a sample row. Picks reasonable widths,\r\ninferred editor types, sensible header labels, and format options for\r\nnumbers / currencies / ISO dates.\r\n\r\n```ts {nocheck}\r\nscaffoldColumns({\r\n sampleRow: { id: 'r1', sellDate: '2026-05-12', price: 1499.99, currency: 'USD' },\r\n inferFormat?: boolean, // default true\r\n language?: 'ts' | 'js', // default 'ts'\r\n})\r\n// → { code: string, columns: ColumnDef[] }\r\n```\r\n\r\n### `validateColumns`\r\n\r\nCheck a `ColumnDef[]` payload against `column-def.json`. Returns the\r\nlist of issues with file / line hints (when the input is a code\r\nstring). Useful for agents that generate columns and want a self-check\r\nbefore showing the result.\r\n\r\n```ts\r\nvalidateColumns({ columns: ColumnDef[] | string }):\r\n { valid: boolean, issues: Array<{ path, message, severity }> }\r\n```\r\n\r\n### `previewExport`\r\n\r\nDry-run an `api.exportData({...})` call. Returns the rows + header\r\nlayout the exporter WOULD write, without actually triggering a\r\ndownload. Useful when an agent is composing a multi-sheet export and\r\nwants to verify column ordering before committing.\r\n\r\n```ts\r\npreviewExport({ format: 'xlsx', rows: [...], columns: [...] }):\r\n { sheets: Array<{ label, header, rows }> }\r\n```\r\n\r\n### `listDemos`\r\n\r\nReturns every demo in `examples/src/demos/` with its title, blurb,\r\ncategory, source path, and the prompt sidecar (see\r\n[LLM grounding](./llm-grounding.md)).\r\n\r\n```ts\r\nlistDemos({ category?: string }):\r\n Array<{ id, title, blurb, category, source, prompt }>\r\n```\r\n\r\n## Resources exposed\r\n\r\nIn addition to tools, the server exposes three MCP **resources** -\r\nread-only documents the client can browse:\r\n\r\n| URI | Content |\r\n| ------------------------------ | ------------------------------------------------- |\r\n| `svgrid://docs/llms.txt` | Topic map (see [llms.txt](/llms.txt)) |\r\n| `svgrid://docs/llms-full.txt` | Concatenated full text of every doc |\r\n| `svgrid://docs/manifest` | `docs.json` route manifest |\r\n| `svgrid://schemas/column-def` | JSON Schema for `ColumnDef` |\r\n| `svgrid://schemas/svgrid-options` | JSON Schema for `<SvGrid>` props |\r\n| `svgrid://schemas/export-options` | JSON Schema for `api.exportData({...})` |\r\n\r\n## Prompts exposed\r\n\r\nPre-built MCP prompts you can invoke directly from a chat:\r\n\r\n- **`/svgrid:scaffold-grid`** - paste a sample row, get a complete\r\n `<SvGrid>` + `tableFeatures` + `ColumnDef[]` setup\r\n- **`/svgrid:refactor-to-pivot`** - hand it a flat-grid component, get\r\n a pivot-grid version\r\n- **`/svgrid:wire-server-side`** - convert client-side data to a\r\n server-side adapter with sort / filter / paginate round-trips\r\n\r\n## Verifying it works\r\n\r\nAfter wiring the server, ask your model: *\"What MCP tools do you have\r\nfrom sv-grid?\"* You should see all six tools listed. If not, check\r\nyour client's MCP log; the most common issue is `npx` not being on\r\nPATH (use the absolute path to the binary instead).\r\n\r\n## Security model\r\n\r\n- The server runs **locally**. No telemetry, no outbound network calls.\r\n- File reads are scoped to your project's `node_modules/@sv-grid/*`\r\n and any `docs/` folder you explicitly pass via the `--docs <dir>` flag.\r\n- `scaffoldColumns` and `previewExport` are pure functions - they\r\n inspect input and emit text. They never write to disk or fetch from\r\n the network.\r\n- See [security](./security.md) for the general supply-chain posture.\r\n\r\n## Building your own MCP integrations\r\n\r\nThe same `docs.json` + JSON Schemas + `llms.txt` files the server uses\r\nare also accessible directly from your docs site\r\n([https://svgrid.com](https://svgrid.com)):\r\n\r\n```ts\r\nconst docs = await fetch('https://svgrid.com/docs.json').then((r) => r.json())\r\nconst schemas = await fetch('https://svgrid.com/schemas/index.json').then((r) => r.json())\r\nconst llms = await fetch('https://svgrid.com/llms-full.txt').then((r) => r.text())\r\n```\r\n\r\nIf you don't want to run the MCP server, building these into your\r\nagent's system prompt gives ~80% of the same value.\r\n\r\n## See also\r\n\r\n- [LLM grounding](./llm-grounding.md) - the same files used by the MCP server, but documented for direct LLM consumption\r\n- [Agents](./agents.md) - how to build an AI agent that drives the live grid\r\n- [AI assistant](./ai.md) - the in-grid AI features (filter / smart-fill / classify / summarise), free in @svgrid/grid\r\n\r\n## Frequently asked questions\r\n\r\n### What is the sv-grid MCP server?\r\n\r\nA Model Context Protocol server that lets AI clients (Claude Desktop, Cursor,\r\nZed, Continue, custom agents) query SvGrid's documentation, scaffold column\r\ndefinitions, and preview exports - all grounded in the schemas the library\r\nships with, so the model answers from current facts instead of guessing.\r\n\r\n### Do I need an API key to run it?\r\n\r\nNo. The MCP server runs locally against your installed copy of SvGrid. There is\r\nno key and no external call.\r\n\r\n### How does it help AI assistants write better SvGrid code?\r\n\r\nIt exposes example sources, the docs, and the API reference as MCP tools, so the\r\nassistant retrieves accurate, version-pinned answers rather than hallucinating\r\nan API from training data.\r\n"
3497
+ "markdown": "# MCP server\r\n\r\nThe SvGrid MCP server lets AI clients (Claude Code, Claude Desktop,\r\nCursor, Zed, Codex, custom agents) query the documentation, read real\r\ndemo source, and scaffold SvelteKit CRUD apps - all grounded in the\r\nfiles this repository ships. No API key required; everything runs\r\nlocally over stdio.\r\n\r\n![An AI coding agent calls the @svgrid/mcp server over the Model Context Protocol, which runs grid tools and returns structured JSON results back to the agent.](/docs-media/grid-mcp.svg)\r\n\r\n> **What is MCP?** Model Context Protocol is the open standard\r\n> ([modelcontextprotocol.io](https://modelcontextprotocol.io)) for\r\n> exposing tools to LLM clients. SvGrid ships an MCP server so the\r\n> model your team already uses can \"see\" the grid without you having\r\n> to copy-paste docs into prompts.\r\n\r\nThe package is [`@svgrid/mcp`](https://www.npmjs.com/package/@svgrid/mcp)\r\non npm, and it is listed in the official MCP registry as\r\n`com.svgrid/svgrid`.\r\n\r\n## Install\r\n\r\nNo install step is required - `npx` fetches it on demand:\r\n\r\n```bash\r\n# One-shot, from any project\r\nnpx -y @svgrid/mcp\r\n```\r\n\r\nTo pin it as a dev dependency instead:\r\n\r\n```bash\r\npnpm add -D @svgrid/mcp\r\n```\r\n\r\nThe server is a Node binary (`svgrid-mcp`) that speaks MCP over stdio.\r\nThere is no daemon to maintain.\r\n\r\n## Wire it into your AI client\r\n\r\n### Claude Code\r\n\r\nOne command:\r\n\r\n```bash\r\nclaude mcp add svgrid -- npx -y @svgrid/mcp\r\n```\r\n\r\nThen run `/mcp` in a session and you will see `svgrid` listed.\r\n\r\nTo share the server with your team, add `--scope project`. That writes\r\na `.mcp.json` at the repository root which you can commit, so everyone\r\nwho clones the repo gets the same tooling with no per-machine setup:\r\n\r\n```bash\r\nclaude mcp add svgrid --scope project -- npx -y @svgrid/mcp\r\n```\r\n\r\n### Claude Desktop\r\n\r\nEdit `~/Library/Application Support/Claude/claude_desktop_config.json`\r\n(macOS) or `%APPDATA%\\Claude\\claude_desktop_config.json` (Windows):\r\n\r\n```json\r\n{\r\n \"mcpServers\": {\r\n \"svgrid\": {\r\n \"command\": \"npx\",\r\n \"args\": [\"-y\", \"@svgrid/mcp\"]\r\n }\r\n }\r\n}\r\n```\r\n\r\nRestart Claude Desktop, then ask *\"using svgrid, build me a grid that\r\ngroups by department\"* to confirm the tools are exposed.\r\n\r\n### Cursor\r\n\r\n`Settings -> MCP -> Add new MCP server`:\r\n\r\n```json\r\n{ \"command\": \"npx\", \"args\": [\"-y\", \"@svgrid/mcp\"] }\r\n```\r\n\r\n### Zed\r\n\r\n`~/.config/zed/settings.json`:\r\n\r\n```json\r\n{\r\n \"context_servers\": {\r\n \"svgrid\": {\r\n \"command\": \"npx\",\r\n \"args\": [\"-y\", \"@svgrid/mcp\"],\r\n \"env\": {}\r\n }\r\n }\r\n}\r\n```\r\n\r\n### VS Code\r\n\r\nCreate `.vscode/mcp.json` in the workspace. Note that VS Code uses\r\n`servers` rather than the `mcpServers` wrapper:\r\n\r\n```json\r\n{\r\n \"servers\": {\r\n \"svgrid\": {\r\n \"type\": \"stdio\",\r\n \"command\": \"npx\",\r\n \"args\": [\"-y\", \"@svgrid/mcp\"]\r\n }\r\n }\r\n}\r\n```\r\n\r\n### Custom agents (OpenAI Agents SDK, Anthropic SDK, LangChain)\r\n\r\nPoint your client's MCP stdio transport at:\r\n\r\n```\r\nnpx -y @svgrid/mcp\r\n```\r\n\r\nAny client that speaks MCP stdio works.\r\n\r\n## Tools exposed\r\n\r\nThe server registers 35 tools: 8 for documentation, examples, and\r\nscaffolding, plus 27 `studio_*` tools that drive the SvGrid Studio\r\nproject model. All run locally; none require an API key or a network\r\ncall.\r\n\r\n### Documentation and examples\r\n\r\nThese six are free and need no license key.\r\n\r\n#### `list_examples`\r\n\r\nList every demo with `id`, `title`, and a one-line blurb. Use it to\r\ndiscover what exists before fetching source.\r\n\r\n```ts\r\nlist_examples(): Array<{ id, title, blurb, path }>\r\n```\r\n\r\n#### `get_example_source`\r\n\r\nReturn the full `.svelte` source of one demo, verbatim, including\r\nimports - the same file a user would copy into a project.\r\n\r\n```ts\r\nget_example_source({ id: '11-stock-market' }): string\r\n```\r\n\r\n#### `list_docs`\r\n\r\nList every documentation page with slug and title. Slugs use forward\r\nslashes, for example `help/columns/column-definitions`.\r\n\r\n```ts\r\nlist_docs(): Array<{ slug, title }>\r\n```\r\n\r\n#### `get_doc`\r\n\r\nReturn the markdown of a single page by slug.\r\n\r\n```ts\r\nget_doc({ slug: 'getting-started' }): string\r\n```\r\n\r\n#### `search_docs`\r\n\r\nCase-insensitive substring search across all docs. Returns matching\r\nslugs with a one-line excerpt around the first hit.\r\n\r\n`limit` is optional and defaults to 10.\r\n\r\n```ts\r\nsearch_docs({ query: 'row virtualization', limit: 10 })\r\n```\r\n\r\n#### `get_api_reference`\r\n\r\nThe curated public-API surface, grouped by category (components,\r\nheadless, scheduler, data ops, export, row models, features,\r\nvirtualization, accessibility, utilities).\r\n\r\n```ts\r\nget_api_reference(): string\r\n```\r\n\r\n### SvGrid Studio (commercial)\r\n\r\nThese tools generate application code. They still run without a\r\nlicense key, but generated files are prefixed with a comment pointing\r\nat [pricing](https://svgrid.com/pricing/). Set `SVGRID_LICENSE_KEY` in\r\nthe MCP server's environment for licensed use (see\r\n[Licensing](#licensing) below).\r\n\r\n#### `introspect_source`\r\n\r\nInfer a draft `EntitySchema` from a data source: either a Drizzle\r\nschema file (`kind: \"drizzle\"`, `source`: the file text) or sample\r\nrows (`kind: \"json\"`, `rows`, `name`). Review and refine the draft\r\nbefore scaffolding.\r\n\r\n```ts\r\nintrospect_source({ kind: 'drizzle', source: '...' })\r\nintrospect_source({ kind: 'json', rows: [...], name: 'orders' })\r\n```\r\n\r\n#### `scaffold_entity`\r\n\r\nGenerate runnable SvelteKit files from an `EntitySchema`: the `$lib`\r\nschema module, a `+server.ts` API route using `createKitHandlers`, and\r\na `+page.svelte` with `SvGrid` and `SvGridEditPanel`.\r\n\r\n`route` defaults to the schema name and `apiRoute` to `/api/{route}`.\r\n\r\n```ts {nocheck}\r\nscaffold_entity(args: {\r\n schema: EntitySchema\r\n route?: string\r\n apiRoute?: string\r\n}): Array<{ path: string; contents: string; description: string }>\r\n```\r\n\r\nGenerated bodies are wrapped in `svgrid:managed` markers, so\r\nregeneration preserves your edits outside them. After writing the\r\nfiles, run the project's own `svelte-check` or `tsc` to verify they\r\ncompile.\r\n\r\n#### The `studio_*` tools\r\n\r\n27 tools let an agent build and edit the same validated project model\r\nthe visual designer uses, then generate the app:\r\n\r\n| Area | Tools |\r\n| ---- | ----- |\r\n| Project | `studio_new_project`, `studio_load_project`, `studio_describe_project`, `studio_validate`, `studio_capabilities`, `studio_get_config`, `studio_generate_app` |\r\n| Entities | `studio_add_entity`, `studio_set_entity_source` |\r\n| Screens | `studio_add_screen`, `studio_update_screen`, `studio_remove_screen`, `studio_set_screen_layout` |\r\n| Blocks and components | `studio_add_block`, `studio_update_block`, `studio_move_block`, `studio_remove_block`, `studio_add_component` |\r\n| Forms | `studio_set_form_layout`, `studio_set_field_conditions` |\r\n| Platform | `studio_set_auth`, `studio_set_access`, `studio_set_tenancy`, `studio_set_data_layer`, `studio_set_deploy_target`, `studio_set_theme`, `studio_set_job` |\r\n\r\nCall `studio_capabilities` first: it reports exactly what the\r\ninstalled version supports, so the agent does not have to guess.\r\n\r\n## Licensing\r\n\r\nThe documentation and example tools are free. The Studio code\r\ngenerators are part of the commercial offering: they run unlicensed,\r\nbut prepend a notice comment to generated files. To license them, set\r\nthe key in your MCP client's server config:\r\n\r\n```json\r\n{\r\n \"mcpServers\": {\r\n \"svgrid\": {\r\n \"command\": \"npx\",\r\n \"args\": [\"-y\", \"@svgrid/mcp\"],\r\n \"env\": { \"SVGRID_LICENSE_KEY\": \"SVENTERPRISE-...\" }\r\n }\r\n }\r\n}\r\n```\r\n\r\n## Verifying it works\r\n\r\nAfter wiring the server, ask your model: *\"What MCP tools do you have\r\nfrom svgrid?\"* You should see the documentation tools and the\r\n`studio_*` set. If not, check your client's MCP log; the most common\r\nissue is `npx` not being on PATH (use the absolute path to the binary\r\ninstead).\r\n\r\n## Security model\r\n\r\n- The server runs **locally** over stdio. No telemetry, no outbound\r\n network calls, no API key.\r\n- It serves a documentation and example corpus bundled into the\r\n package at build time, so answers are pinned to the version you\r\n installed.\r\n- The Studio tools return generated files as data. Writing them to\r\n disk is your client's decision, not the server's.\r\n- See [security](./security.md) for the general supply-chain posture.\r\n\r\n## Building your own MCP integrations\r\n\r\nThe same docs manifest, JSON Schemas, and `llms.txt` files are also\r\nserved directly from the docs site:\r\n\r\n```ts\r\nconst docs = await fetch('https://svgrid.com/docs.json').then((r) => r.json())\r\nconst schemas = await fetch('https://svgrid.com/schemas/index.json').then((r) => r.json())\r\nconst llms = await fetch('https://svgrid.com/llms-full.txt').then((r) => r.text())\r\n```\r\n\r\nIf you do not want to run the MCP server, building these into your\r\nagent's system prompt gives most of the same grounding.\r\n\r\n## See also\r\n\r\n- [LLM grounding](./llm-grounding.md) - the same files used by the MCP server, but documented for direct LLM consumption\r\n- [Agents](./agents.md) - how to build an AI agent that drives the live grid\r\n- [AI assistant](./ai.md) - the in-grid AI features (filter / smart-fill / classify / summarise), free in @svgrid/grid\r\n\r\n## Frequently asked questions\r\n\r\n### What is the SvGrid MCP server?\r\n\r\nA Model Context Protocol server that lets AI clients (Claude Code, Claude\r\nDesktop, Cursor, Zed, custom agents) query SvGrid's documentation, read real\r\ndemo source, and scaffold SvelteKit CRUD apps - grounded in the files the\r\npackage ships, so the model answers from current facts instead of guessing.\r\n\r\n### Do I need an API key to run it?\r\n\r\nNo. The MCP server runs locally over stdio. There is no key and no external\r\ncall. A `SVGRID_LICENSE_KEY` is optional and only affects the commercial\r\nStudio code generators.\r\n\r\n### How does it help AI assistants write better SvGrid code?\r\n\r\nIt exposes example sources, the docs, and the API reference as MCP tools, so\r\nthe assistant retrieves accurate, version-pinned answers rather than\r\nhallucinating an API from training data. That matters most for Svelte 5, where\r\nmodels routinely mix in outdated Svelte 4 syntax.\r\n"
3480
3498
  },
3481
3499
  {
3482
3500
  "slug": "help/migrating-from-ag-grid",
3483
3501
  "path": "docs/help/migrating-from-ag-grid.md",
3484
3502
  "title": "Migrating from AG Grid to SvGrid",
3485
- "markdown": "# Migrating from AG Grid to SvGrid\r\n\r\nIf you tried AG Grid on a Svelte 5 project - via `ag-grid-svelte`, the\r\nold `ag-grid-community/svelte`, or a hand-rolled wrapper - you probably\r\nhit the same friction everyone hits: the bridge between AG Grid's\r\nReact/Angular-first API and Svelte 5 runes is brittle, the bundle is\r\nheavy, and the Enterprise pricing only makes sense at scale.\r\n\r\nThis page is a 30-minute migration recipe from AG Grid to SvGrid. It\r\ncovers what maps 1:1, what's different by design, and what you'll lose.\r\nWe tell you when **not** to switch at the bottom.\r\n\r\n## TL;DR\r\n\r\n| | AG Grid Community | AG Grid Enterprise | SvGrid Community | @svgrid/enterprise |\r\n| --- | --- | --- | --- | --- |\r\n| **License** | MIT | Commercial (~$999/dev/yr) | **MIT** | $599/dev/yr (single app) or $999/dev/yr (multi app) |\r\n| **Svelte 5 native** | ❌ (wrapper) | ❌ (wrapper) | ✅ | ✅ |\r\n| **Bundle (gzipped)** | ~250 KB | ~400 KB | ~2 KB headless / ~78 KB full | lazy-loaded subpaths |\r\n| **Sorting / filtering / grouping** | ✅ | ✅ | ✅ | (in Community) |\r\n| **Master/detail, tree, range select** | ❌ Enterprise only | ✅ | ✅ (free) | (in Community) |\r\n| **Excel export** | ❌ | ✅ Enterprise | ❌ | ✅ |\r\n| **PDF / CSV / TSV / HTML export** | ❌ | Partial | ❌ | ✅ |\r\n| **Print view** | ❌ | ❌ | ❌ | ✅ |\r\n| **Set filter / Excel-style filter menu** | ❌ Enterprise | ✅ | ✅ (free) | (in Community) |\r\n\r\n**SvGrid Community gives you most of AG Grid Enterprise's features for\r\nfree**, and `@svgrid/enterprise` adds the export + print pack for ~40%\r\nless than AG Grid Enterprise. The trade-offs are Svelte-only and a much\r\nsmaller ecosystem.\r\n\r\n## Mental model - what changes\r\n\r\nAG Grid is one big object you configure declaratively. SvGrid is a\r\n**headless engine** (`createSvGrid`) with an optional **render\r\ncomponent** (`<SvGrid>`) on top - the same split TanStack Table made\r\npopular. You can use either layer; most projects use the render\r\ncomponent.\r\n\r\n```svelte\r\n<!-- AG Grid (via a Svelte wrapper) -->\r\n<AgGridSvelte\r\n gridOptions={{\r\n rowData: rows,\r\n columnDefs: columns,\r\n onGridReady: (params) => (gridApi = params.api),\r\n }}\r\n/>\r\n\r\n<!-- SvGrid -->\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n onApiReady={(api) => (gridApi = api)}\r\n/>\r\n```\r\n\r\nThree things to note:\r\n\r\n1. **No giant `gridOptions` blob.** Each capability is a top-level prop.\r\n2. **Features are opt-in.** You pass a `features` object built with\r\n `tableFeatures({...})` - only the features you list ship JS.\r\n3. **`onApiReady`** gives you a typed `SvGridApi` that is roughly the\r\n AG Grid `gridApi` equivalent (see the API-mapping table below).\r\n\r\n## Column definitions - direct translation\r\n\r\nThe shapes are similar enough that you can usually translate by hand\r\nwithout thinking too hard.\r\n\r\n```ts\r\n// AG Grid\r\nconst columnDefs: ColDef[] = [\r\n { field: 'name', headerName: 'Name', sortable: true, filter: true, width: 200 },\r\n { field: 'price', headerName: 'Price', type: 'numericColumn',\r\n valueFormatter: ({ value }) => `$${value.toFixed(2)}` },\r\n { field: 'date', headerName: 'Date',\r\n valueGetter: ({ data }) => new Date(data.date).toISOString().slice(0, 10) },\r\n { field: 'status', headerName: 'Status',\r\n cellRenderer: StatusCellRenderer,\r\n cellRendererParams: { onChange: handleStatusChange } },\r\n]\r\n```\r\n\r\n```ts\r\n// SvGrid\r\nimport { renderComponent, type ColumnDef } from '@svgrid/grid'\r\nimport StatusCell from './StatusCell.svelte'\r\n\r\nconst columns: ColumnDef<typeof features, Row>[] = [\r\n { field: 'name', header: 'Name', width: 200 }, // sortable + filterable by default\r\n { field: 'price', header: 'Price',\r\n format: { type: 'currency', currency: 'USD' } },\r\n { field: 'date', header: 'Date',\r\n format: { type: 'date', pattern: 'y-m-d' } },\r\n { field: 'status', header: 'Status',\r\n cell: renderComponent(StatusCell, (ctx) => ({\r\n value: ctx.getValue(),\r\n onChange: handleStatusChange,\r\n })),\r\n },\r\n]\r\n```\r\n\r\n### Property mapping\r\n\r\n| AG Grid | SvGrid | Notes |\r\n| --- | --- | --- |\r\n| `field` | `field` | Same. |\r\n| `headerName` | `header` | Accepts a string or a snippet/component. |\r\n| `width` | `width` | Same. |\r\n| `minWidth` / `maxWidth` | `minWidth` / `maxWidth` | Same. |\r\n| `sortable: true` | (default) | Sorting is on when `rowSortingFeature` is registered. |\r\n| `filter: true` | (default) | Filtering is on when `columnFilteringFeature` is registered. |\r\n| `valueFormatter` | `format: { ... }` | Built-in types: `number`, `currency`, `percent`, `date`. For custom, use `cell`. |\r\n| `valueGetter` | `fieldFn` | Returns the value for sorting/filtering. |\r\n| `cellRenderer` + `cellRendererParams` | `cell: renderComponent(C, ctx => props)` | One call, type-checked. |\r\n| `cellEditor: 'agTextCellEditor'` | `editorType: 'text'` | Built-in: `text`, `number`, `checkbox`, `date`. |\r\n| `editable: true` | `enableInlineEditing` prop on `<SvGrid>` | Per-grid, not per-column. (Per-column control on the roadmap.) |\r\n| `pinned: 'left'` / `'right'` | Right-click column menu → Pin | Set programmatically via the api. |\r\n| `rowGroup: true` | Via `setGroupBy([colId])` | See Grouping below. |\r\n| `aggFunc: 'sum'` | `aggregation: 'sum'` | Built-in: `sum`, `avg`, `min`, `max`, `count`. |\r\n\r\n## Feature registration - the one new thing\r\n\r\nAG Grid auto-enables most features; you turn them off. SvGrid is the\r\nopposite - features are opt-in. The result is a smaller bundle.\r\n\r\n```ts\r\nimport {\r\n tableFeatures,\r\n rowSortingFeature,\r\n columnFilteringFeature,\r\n columnGroupingFeature,\r\n rowExpandingFeature,\r\n rowPaginationFeature,\r\n rowSelectionFeature,\r\n} from '@svgrid/grid'\r\n\r\nconst features = tableFeatures({\r\n rowSortingFeature,\r\n columnFilteringFeature,\r\n rowSelectionFeature,\r\n // omit any you don't need - their code won't ship\r\n})\r\n```\r\n\r\nPass `features` to `<SvGrid>` once. From then on the grid behaves like\r\nAG Grid's `enableSorting`, `enableFilter`, `rowSelection`, etc. are all\r\non for the registered features.\r\n\r\n## API mapping (`gridApi` → `SvGridApi`)\r\n\r\nYou get the SvGrid API from `onApiReady` (equivalent to AG Grid's\r\n`onGridReady`).\r\n\r\n| AG Grid `gridApi.X()` | SvGrid `api.X()` |\r\n| --- | --- |\r\n| `setRowData(rows)` | (declarative - just update `data` prop) |\r\n| `addRow(row)` / `applyTransaction({ add: [row] })` | `api.addRow(row)` / `api.addRows(rows)` |\r\n| `applyTransaction({ remove: [row] })` | `api.removeRow(rowIndex)` |\r\n| `getValue(colId, rowNode)` | `api.getCellValue(rowIndex, columnId)` |\r\n| `setValue(...)` | `api.setCellValue(rowIndex, columnId, value)` |\r\n| `setColumnVisible(colId, visible)` | `api.setColumnVisible(columnId, visible)` |\r\n| `getSortModel()` / `setSortModel()` | `api.setSort(columnId, 'asc'\\|'desc'\\|null)` |\r\n| `setFilterModel({...})` | `api.setFilter(columnId, { operator, value })` |\r\n| `getDisplayedRowAtIndex(i)` / `forEachNodeAfterFilterAndSort(...)` | `api.getDisplayedRows()` |\r\n| `getModel()` (raw rows) | `api.getData()` |\r\n\r\n## Common patterns\r\n\r\n### Sorting + filtering + pagination (the 80% case)\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import {\r\n SvGrid,\r\n tableFeatures,\r\n rowSortingFeature,\r\n columnFilteringFeature,\r\n rowPaginationFeature,\r\n } from '@svgrid/grid'\r\n\r\n const features = tableFeatures({\r\n rowSortingFeature,\r\n columnFilteringFeature,\r\n rowPaginationFeature,\r\n })\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n showPagination\r\n showColumnFilters\r\n/>\r\n```\r\n\r\nAG Grid equivalent: `gridOptions: { defaultColDef: { sortable: true, filter: true }, pagination: true }`.\r\n\r\n### Cell editing with persistence\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n function onCellValueChange(e: { rowIndex: number; columnId: string; value: unknown }) {\r\n // Persist however you like (fetch to backend, optimistic local update, etc.)\r\n console.log('cell changed', e)\r\n }\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n enableInlineEditing\r\n onCellValueChange={onCellValueChange}\r\n/>\r\n```\r\n\r\nAG Grid equivalent: `onCellValueChanged: ({ data, colDef, newValue, oldValue }) => ...`.\r\nSvGrid's event payload is column-id + row-index based rather than node-based; the row data is yours\r\nto mutate (or not) on the `rows` array you passed in.\r\n\r\n### Grouping + aggregation\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import {\r\n SvGrid,\r\n tableFeatures,\r\n columnGroupingFeature,\r\n rowSortingFeature,\r\n rowExpandingFeature,\r\n } from '@svgrid/grid'\r\n\r\n const features = tableFeatures({\r\n rowSortingFeature,\r\n columnGroupingFeature,\r\n rowExpandingFeature,\r\n })\r\n\r\n const columns = [\r\n { field: 'department', header: 'Department' },\r\n { field: 'team', header: 'Team' },\r\n { field: 'salary', header: 'Salary', aggregation: 'sum',\r\n format: { type: 'currency', currency: 'USD' } },\r\n ]\r\n\r\n function setGroup(api) {\r\n api.setGroupBy(['department', 'team'])\r\n }\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n showGroupingControls\r\n onApiReady={setGroup}\r\n/>\r\n```\r\n\r\nAG Grid Enterprise's `rowGroupPanelShow: 'always'` + `aggFunc: 'sum'` translates 1:1.\r\n\r\n### Master / detail\r\n\r\nAG Grid Enterprise feature; **free in SvGrid Community**. See\r\n[demo 08](https://svgrid.com/demos/08-tree-and-master-detail/) for the exact pattern.\r\n\r\n### Server-side data\r\n\r\nAG Grid uses an `IServerSideDatasource` interface. SvGrid uses\r\n`externalSort` + `externalFilter` props - your code keeps full control\r\nover the query, and the grid records UI state but doesn't re-order rows\r\nlocally. See [demo 09](https://svgrid.com/demos/09-server-side/).\r\n\r\n### Excel / PDF export\r\n\r\nAG Grid: `gridApi.exportDataAsExcel({...})` (Enterprise-only).\r\nSvGrid: install `@svgrid/enterprise`, call `api.exportData({ format: 'xlsx', ... })`. See [Data export and printing](./export.md).\r\n\r\n```ts\r\nimport { installEnterprise, setLicenseKey } from '@svgrid/enterprise'\r\nsetLicenseKey('SVENTERPRISE-...') // your Enterprise key\r\n\r\n// inside onApiReady:\r\nconst pro = installEnterprise(api)\r\nawait pro.exportData({ format: 'xlsx', filename: 'orders' })\r\n```\r\n\r\n## Gotchas - things that don't translate directly\r\n\r\n### 1. Per-column `editable: true`\r\nSvGrid v1.0 toggles editing at the grid level (`enableInlineEditing`).\r\nPer-column editability is on the roadmap - until then, you can gate\r\nedits in your `onCellValueChange` handler.\r\n\r\n### 2. Column drag-to-reorder\r\nSvGrid v1.0 supports column reorder via the API (`setColumnOrder`), not\r\nheader drag. Built-in header drag is on the roadmap. Most teams don't\r\nmiss it - it's a power-user feature.\r\n\r\n### 3. AG Grid `valueGetter` chains\r\nAG Grid's `valueGetter` can read other column values via the API. In\r\nSvGrid, `fieldFn` only receives the row; if you need cross-column\r\ncomputed values, do it in the cell renderer with `ctx.row.original` or\r\ncompute the derived value upstream and store it in the row.\r\n\r\n### 4. `cellClass` / `rowClass` callbacks\r\nOn the roadmap. For now, render a wrapper element in your `cell` snippet\r\nwith the conditional class.\r\n\r\n### 5. The Status Bar / Side Bar / Tool Panels\r\nAG Grid's chrome (status bar with row count, side bar with filters and\r\ncolumns panels) doesn't exist in SvGrid - build it as plain Svelte\r\nmarkup around the grid. Most teams build their own anyway because\r\nAG Grid's defaults rarely match a polished design system.\r\n\r\n### 6. Set filter (the Excel-style funnel popup)\r\nSvGrid ships an Excel-style filter menu (free in Community). API surface\r\nis similar but not identical - see [Set filter](./filtering/set-filter.md).\r\n\r\n## When NOT to migrate\r\n\r\nBe honest. Stay on AG Grid if you:\r\n\r\n- **Use multiple frameworks** - AG Grid has React, Angular, Vue, Solid, Qwik, vanilla adapters. SvGrid is Svelte-only.\r\n- **Use AG Grid's integrated charts** - those depend on AG Grid's chart engine; SvGrid has no equivalent.\r\n- **Depend on AG Grid pivoting** - not in SvGrid's roadmap.\r\n- **Are mid-project and shipping in <2 weeks** - the migration is a few hours per grid, but only do it when you have buffer.\r\n- **Have a Svelte 4 codebase you can't upgrade** - SvGrid requires Svelte 5 runes. (Consider [htmlelements.com](https://www.htmlelements.com) for vanilla / multi-framework.)\r\n\r\nIf none of those apply: switching saves you $400-$1000 per dev per year,\r\ncuts your bundle by 200+ KB, and gives you a Svelte-native API that\r\nplays well with runes.\r\n\r\n## Step-by-step migration\r\n\r\nA typical migration of a single grid takes 1-3 hours:\r\n\r\n1. **Install** - `pnpm add @svgrid/grid` (and `@svgrid/enterprise` if you need export).\r\n2. **Translate columnDefs** - use the mapping table above. Most columns are 1:1.\r\n3. **Wrap features** - figure out which AG Grid features you actually use; register only those in `tableFeatures({...})`.\r\n4. **Swap the component** - `<AgGridSvelte gridOptions={...}>` → `<SvGrid data={rows} columns={columns} features={features}>`.\r\n5. **Move event handlers** - AG Grid `onCellValueChanged` → SvGrid `onCellValueChange` (signature differs slightly, see above).\r\n6. **Move API calls** - AG Grid `gridApi.X()` → SvGrid `api.X()` per the API table.\r\n7. **Test interactions** - sort, filter, edit, select. Most \"just works.\"\r\n8. **Remove `ag-grid-*` packages** - `pnpm remove ag-grid-community ag-grid-svelte` etc. Inspect your bundle to confirm the 200+ KB drop.\r\n\r\n## Need help migrating?\r\n\r\nEnterprise customers get **migration help included** with the support plan\r\n(architecture review, port one grid for you as a reference). Email\r\n`support@jqwidgets.com` after purchase, or `sales@jqwidgets.com` for\r\npre-sales questions.\r\n\r\n## See also\r\n\r\n- [Getting started](../getting-started.md) - full SvGrid walkthrough\r\n- [Why headless?](../why-headless.md) - the headless / render-component split\r\n- [Data export and printing](./export.md) - the `@svgrid/enterprise` feature pack\r\n- [SvGrid vs AG Grid comparison page](https://svgrid.com/compare/ag-grid/)\r\n\r\n## Frequently asked questions\r\n\r\n### Is SvGrid a drop-in replacement for AG Grid in Svelte?\r\n\r\nNot a literal drop-in - there is no `ag-grid-svelte` shim to swap. But the\r\nconcepts map closely: column definitions, row models, sorting, filtering,\r\ngrouping, and an imperative API all have direct SvGrid equivalents, so most\r\nteams port a grid in 30 minutes to a day. It is a configuration translation,\r\nnot a rewrite.\r\n\r\n### What is the SvGrid equivalent of AG Grid Enterprise?\r\n\r\n`@svgrid/enterprise`. It adds Excel/PDF/CSV/TSV/HTML export, a printable view, pivot\r\ntables, data import, and AI helpers. It is licensed per developer\r\n($599 single-app / $999 multi-app), not per deployment, and the Community\r\npackage is MIT-licensed and free for commercial use.\r\n\r\n### Does SvGrid use Svelte 5 runes, or is it a wrapper?\r\n\r\nIt is Svelte-5-native. State is `$state` / `$derived` / `$effect` and cells\r\nrender through Svelte snippets - there is no React or Angular core underneath\r\nand no framework bridge to keep in sync.\r\n\r\n### Will my AG Grid bundle size shrink?\r\n\r\nAlmost always. SvGrid's full render component is ~78 KB gzipped (or ~2 KB\r\nfor the headless core) versus a much heavier AG Grid Community bundle, and you\r\nonly add `@svgrid/enterprise` features you actually use - so you ship a fraction of\r\nthe JavaScript.\r\n"
3503
+ "markdown": "# Migrating from AG Grid to SvGrid\r\n\r\nIf you tried AG Grid on a Svelte 5 project - via `ag-grid-svelte`, the\r\nold `ag-grid-community/svelte`, or a hand-rolled wrapper - you probably\r\nhit the same friction everyone hits: the bridge between AG Grid's\r\nReact/Angular-first API and Svelte 5 runes is brittle, the bundle is\r\nheavy, and the Enterprise pricing only makes sense at scale.\r\n\r\nThis page is a 30-minute migration recipe from AG Grid to SvGrid. It\r\ncovers what maps 1:1, what's different by design, and what you'll lose.\r\nWe tell you when **not** to switch at the bottom.\r\n\r\n## TL;DR\r\n\r\n| | AG Grid Community | AG Grid Enterprise | SvGrid Community | @svgrid/enterprise |\r\n| --- | --- | --- | --- | --- |\r\n| **License** | MIT | Commercial (~$999/dev/yr) | **MIT** | $599/dev/yr (single app) or $999/dev/yr (multi app) |\r\n| **Svelte 5 native** | ❌ (wrapper) | ❌ (wrapper) | ✅ | ✅ |\r\n| **Bundle (gzipped)** | ~250 KB | ~400 KB | ~2 KB headless / ~77 KB full | lazy-loaded subpaths |\r\n| **Sorting / filtering / grouping** | ✅ | ✅ | ✅ | (in Community) |\r\n| **Master/detail, tree, range select** | ❌ Enterprise only | ✅ | ✅ (free) | (in Community) |\r\n| **Excel export** | ❌ | ✅ Enterprise | ❌ | ✅ |\r\n| **PDF / CSV / TSV / HTML export** | ❌ | Partial | ❌ | ✅ |\r\n| **Print view** | ❌ | ❌ | ❌ | ✅ |\r\n| **Set filter / Excel-style filter menu** | ❌ Enterprise | ✅ | ✅ (free) | (in Community) |\r\n\r\n**SvGrid Community gives you most of AG Grid Enterprise's features for\r\nfree**, and `@svgrid/enterprise` adds the export + print pack for ~40%\r\nless than AG Grid Enterprise. The trade-offs are Svelte-only and a much\r\nsmaller ecosystem.\r\n\r\n## Mental model - what changes\r\n\r\nAG Grid is one big object you configure declaratively. SvGrid is a\r\n**headless engine** (`createSvGrid`) with an optional **render\r\ncomponent** (`<SvGrid>`) on top - the same split TanStack Table made\r\npopular. You can use either layer; most projects use the render\r\ncomponent.\r\n\r\n```svelte\r\n<!-- AG Grid (via a Svelte wrapper) -->\r\n<AgGridSvelte\r\n gridOptions={{\r\n rowData: rows,\r\n columnDefs: columns,\r\n onGridReady: (params) => (gridApi = params.api),\r\n }}\r\n/>\r\n\r\n<!-- SvGrid -->\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n onApiReady={(api) => (gridApi = api)}\r\n/>\r\n```\r\n\r\nThree things to note:\r\n\r\n1. **No giant `gridOptions` blob.** Each capability is a top-level prop.\r\n2. **Features are opt-in.** You pass a `features` object built with\r\n `tableFeatures({...})` - only the features you list ship JS.\r\n3. **`onApiReady`** gives you a typed `SvGridApi` that is roughly the\r\n AG Grid `gridApi` equivalent (see the API-mapping table below).\r\n\r\n## Column definitions - direct translation\r\n\r\nThe shapes are similar enough that you can usually translate by hand\r\nwithout thinking too hard.\r\n\r\n```ts\r\n// AG Grid\r\nconst columnDefs: ColDef[] = [\r\n { field: 'name', headerName: 'Name', sortable: true, filter: true, width: 200 },\r\n { field: 'price', headerName: 'Price', type: 'numericColumn',\r\n valueFormatter: ({ value }) => `$${value.toFixed(2)}` },\r\n { field: 'date', headerName: 'Date',\r\n valueGetter: ({ data }) => new Date(data.date).toISOString().slice(0, 10) },\r\n { field: 'status', headerName: 'Status',\r\n cellRenderer: StatusCellRenderer,\r\n cellRendererParams: { onChange: handleStatusChange } },\r\n]\r\n```\r\n\r\n```ts\r\n// SvGrid\r\nimport { renderComponent, type ColumnDef } from '@svgrid/grid'\r\nimport StatusCell from './StatusCell.svelte'\r\n\r\nconst columns: ColumnDef<typeof features, Row>[] = [\r\n { field: 'name', header: 'Name', width: 200 }, // sortable + filterable by default\r\n { field: 'price', header: 'Price',\r\n format: { type: 'currency', currency: 'USD' } },\r\n { field: 'date', header: 'Date',\r\n format: { type: 'date', pattern: 'y-m-d' } },\r\n { field: 'status', header: 'Status',\r\n cell: renderComponent(StatusCell, (ctx) => ({\r\n value: ctx.getValue(),\r\n onChange: handleStatusChange,\r\n })),\r\n },\r\n]\r\n```\r\n\r\n### Property mapping\r\n\r\n| AG Grid | SvGrid | Notes |\r\n| --- | --- | --- |\r\n| `field` | `field` | Same. |\r\n| `headerName` | `header` | Accepts a string or a snippet/component. |\r\n| `width` | `width` | Same. |\r\n| `minWidth` / `maxWidth` | `minWidth` / `maxWidth` | Same. |\r\n| `sortable: true` | (default) | Sorting is on when `rowSortingFeature` is registered. |\r\n| `filter: true` | (default) | Filtering is on when `columnFilteringFeature` is registered. |\r\n| `valueFormatter` | `format: { ... }` | Built-in types: `number`, `currency`, `percent`, `date`. For custom, use `cell`. |\r\n| `valueGetter` | `fieldFn` | Returns the value for sorting/filtering. |\r\n| `cellRenderer` + `cellRendererParams` | `cell: renderComponent(C, ctx => props)` | One call, type-checked. |\r\n| `cellEditor: 'agTextCellEditor'` | `editorType: 'text'` | Built-in: `text`, `number`, `checkbox`, `date`. |\r\n| `editable: true` | `enableInlineEditing` prop on `<SvGrid>` | Per-grid, not per-column. (Per-column control on the roadmap.) |\r\n| `pinned: 'left'` / `'right'` | Right-click column menu → Pin | Set programmatically via the api. |\r\n| `rowGroup: true` | Via `setGroupBy([colId])` | See Grouping below. |\r\n| `aggFunc: 'sum'` | `aggregation: 'sum'` | Built-in: `sum`, `avg`, `min`, `max`, `count`. |\r\n\r\n## Feature registration - the one new thing\r\n\r\nAG Grid auto-enables most features; you turn them off. SvGrid is the\r\nopposite - features are opt-in. The result is a smaller bundle.\r\n\r\n```ts\r\nimport {\r\n tableFeatures,\r\n rowSortingFeature,\r\n columnFilteringFeature,\r\n columnGroupingFeature,\r\n rowExpandingFeature,\r\n rowPaginationFeature,\r\n rowSelectionFeature,\r\n} from '@svgrid/grid'\r\n\r\nconst features = tableFeatures({\r\n rowSortingFeature,\r\n columnFilteringFeature,\r\n rowSelectionFeature,\r\n // omit any you don't need - their code won't ship\r\n})\r\n```\r\n\r\nPass `features` to `<SvGrid>` once. From then on the grid behaves like\r\nAG Grid's `enableSorting`, `enableFilter`, `rowSelection`, etc. are all\r\non for the registered features.\r\n\r\n## API mapping (`gridApi` → `SvGridApi`)\r\n\r\nYou get the SvGrid API from `onApiReady` (equivalent to AG Grid's\r\n`onGridReady`).\r\n\r\n| AG Grid `gridApi.X()` | SvGrid `api.X()` |\r\n| --- | --- |\r\n| `setRowData(rows)` | (declarative - just update `data` prop) |\r\n| `addRow(row)` / `applyTransaction({ add: [row] })` | `api.addRow(row)` / `api.addRows(rows)` |\r\n| `applyTransaction({ remove: [row] })` | `api.removeRow(rowIndex)` |\r\n| `getValue(colId, rowNode)` | `api.getCellValue(rowIndex, columnId)` |\r\n| `setValue(...)` | `api.setCellValue(rowIndex, columnId, value)` |\r\n| `setColumnVisible(colId, visible)` | `api.setColumnVisible(columnId, visible)` |\r\n| `getSortModel()` / `setSortModel()` | `api.setSort(columnId, 'asc'\\|'desc'\\|null)` |\r\n| `setFilterModel({...})` | `api.setFilter(columnId, { operator, value })` |\r\n| `getDisplayedRowAtIndex(i)` / `forEachNodeAfterFilterAndSort(...)` | `api.getDisplayedRows()` |\r\n| `getModel()` (raw rows) | `api.getData()` |\r\n\r\n## Common patterns\r\n\r\n### Sorting + filtering + pagination (the 80% case)\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import {\r\n SvGrid,\r\n tableFeatures,\r\n rowSortingFeature,\r\n columnFilteringFeature,\r\n rowPaginationFeature,\r\n } from '@svgrid/grid'\r\n\r\n const features = tableFeatures({\r\n rowSortingFeature,\r\n columnFilteringFeature,\r\n rowPaginationFeature,\r\n })\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n showPagination\r\n showColumnFilters\r\n/>\r\n```\r\n\r\nAG Grid equivalent: `gridOptions: { defaultColDef: { sortable: true, filter: true }, pagination: true }`.\r\n\r\n### Cell editing with persistence\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n function onCellValueChange(e: { rowIndex: number; columnId: string; value: unknown }) {\r\n // Persist however you like (fetch to backend, optimistic local update, etc.)\r\n console.log('cell changed', e)\r\n }\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n enableInlineEditing\r\n onCellValueChange={onCellValueChange}\r\n/>\r\n```\r\n\r\nAG Grid equivalent: `onCellValueChanged: ({ data, colDef, newValue, oldValue }) => ...`.\r\nSvGrid's event payload is column-id + row-index based rather than node-based; the row data is yours\r\nto mutate (or not) on the `rows` array you passed in.\r\n\r\n### Grouping + aggregation\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import {\r\n SvGrid,\r\n tableFeatures,\r\n columnGroupingFeature,\r\n rowSortingFeature,\r\n rowExpandingFeature,\r\n } from '@svgrid/grid'\r\n\r\n const features = tableFeatures({\r\n rowSortingFeature,\r\n columnGroupingFeature,\r\n rowExpandingFeature,\r\n })\r\n\r\n const columns = [\r\n { field: 'department', header: 'Department' },\r\n { field: 'team', header: 'Team' },\r\n { field: 'salary', header: 'Salary', aggregation: 'sum',\r\n format: { type: 'currency', currency: 'USD' } },\r\n ]\r\n\r\n function setGroup(api) {\r\n api.setGroupBy(['department', 'team'])\r\n }\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n showGroupingControls\r\n onApiReady={setGroup}\r\n/>\r\n```\r\n\r\nAG Grid Enterprise's `rowGroupPanelShow: 'always'` + `aggFunc: 'sum'` translates 1:1.\r\n\r\n### Master / detail\r\n\r\nAG Grid Enterprise feature; **free in SvGrid Community**. See\r\n[demo 08](https://svgrid.com/demos/08-tree-and-master-detail/) for the exact pattern.\r\n\r\n### Server-side data\r\n\r\nAG Grid uses an `IServerSideDatasource` interface. SvGrid uses\r\n`externalSort` + `externalFilter` props - your code keeps full control\r\nover the query, and the grid records UI state but doesn't re-order rows\r\nlocally. See [demo 09](https://svgrid.com/demos/09-server-side/).\r\n\r\n### Excel / PDF export\r\n\r\nAG Grid: `gridApi.exportDataAsExcel({...})` (Enterprise-only).\r\nSvGrid: install `@svgrid/enterprise`, call `api.exportData({ format: 'xlsx', ... })`. See [Data export and printing](./export.md).\r\n\r\n```ts\r\nimport { installEnterprise, setLicenseKey } from '@svgrid/enterprise'\r\nsetLicenseKey('SVENTERPRISE-...') // your Enterprise key\r\n\r\n// inside onApiReady:\r\nconst pro = installEnterprise(api)\r\nawait pro.exportData({ format: 'xlsx', filename: 'orders' })\r\n```\r\n\r\n## Gotchas - things that don't translate directly\r\n\r\n### 1. Per-column `editable: true`\r\nSvGrid v1.0 toggles editing at the grid level (`enableInlineEditing`).\r\nPer-column editability is on the roadmap - until then, you can gate\r\nedits in your `onCellValueChange` handler.\r\n\r\n### 2. Column drag-to-reorder\r\nSvGrid v1.0 supports column reorder via the API (`setColumnOrder`), not\r\nheader drag. Built-in header drag is on the roadmap. Most teams don't\r\nmiss it - it's a power-user feature.\r\n\r\n### 3. AG Grid `valueGetter` chains\r\nAG Grid's `valueGetter` can read other column values via the API. In\r\nSvGrid, `fieldFn` only receives the row; if you need cross-column\r\ncomputed values, do it in the cell renderer with `ctx.row.original` or\r\ncompute the derived value upstream and store it in the row.\r\n\r\n### 4. `cellClass` / `rowClass` callbacks\r\nOn the roadmap. For now, render a wrapper element in your `cell` snippet\r\nwith the conditional class.\r\n\r\n### 5. The Status Bar / Side Bar / Tool Panels\r\nAG Grid's chrome (status bar with row count, side bar with filters and\r\ncolumns panels) doesn't exist in SvGrid - build it as plain Svelte\r\nmarkup around the grid. Most teams build their own anyway because\r\nAG Grid's defaults rarely match a polished design system.\r\n\r\n### 6. Set filter (the Excel-style funnel popup)\r\nSvGrid ships an Excel-style filter menu (free in Community). API surface\r\nis similar but not identical - see [Set filter](./filtering/set-filter.md).\r\n\r\n## When NOT to migrate\r\n\r\nBe honest. Stay on AG Grid if you:\r\n\r\n- **Use multiple frameworks** - AG Grid has React, Angular, Vue, Solid, Qwik, vanilla adapters. SvGrid is Svelte-only.\r\n- **Need server-side pivoting or a push-based viewport row model** - SvGrid ships pivot, integrated charts and a server-side row model (sort / filter / group / infinite), but not those two.\r\n- **Need pluggable custom filter components or custom tool panels** - SvGrid's tool panel is a fixed Columns + Filters pair.\r\n- **Are mid-project and shipping in <2 weeks** - the migration is a few hours per grid, but only do it when you have buffer.\r\n- **Have a Svelte 4 codebase you can't upgrade** - SvGrid requires Svelte 5 runes. (Consider [htmlelements.com](https://www.htmlelements.com) for vanilla / multi-framework.)\r\n\r\nIf none of those apply: switching saves you $400-$1000 per dev per year,\r\ncuts your bundle by 200+ KB, and gives you a Svelte-native API that\r\nplays well with runes.\r\n\r\n## Step-by-step migration\r\n\r\nA typical migration of a single grid takes 1-3 hours:\r\n\r\n1. **Install** - `pnpm add @svgrid/grid` (and `@svgrid/enterprise` if you need export).\r\n2. **Translate columnDefs** - use the mapping table above. Most columns are 1:1.\r\n3. **Wrap features** - figure out which AG Grid features you actually use; register only those in `tableFeatures({...})`.\r\n4. **Swap the component** - `<AgGridSvelte gridOptions={...}>` → `<SvGrid data={rows} columns={columns} features={features}>`.\r\n5. **Move event handlers** - AG Grid `onCellValueChanged` → SvGrid `onCellValueChange` (signature differs slightly, see above).\r\n6. **Move API calls** - AG Grid `gridApi.X()` → SvGrid `api.X()` per the API table.\r\n7. **Test interactions** - sort, filter, edit, select. Most \"just works.\"\r\n8. **Remove `ag-grid-*` packages** - `pnpm remove ag-grid-community ag-grid-svelte` etc. Inspect your bundle to confirm the 200+ KB drop.\r\n\r\n## Need help migrating?\r\n\r\nEnterprise customers get **migration help included** with the support plan\r\n(architecture review, port one grid for you as a reference). Email\r\n`support@jqwidgets.com` after purchase, or `sales@jqwidgets.com` for\r\npre-sales questions.\r\n\r\n## See also\r\n\r\n- [Getting started](../getting-started.md) - full SvGrid walkthrough\r\n- [Why headless?](../why-headless.md) - the headless / render-component split\r\n- [Data export and printing](./export.md) - the `@svgrid/enterprise` feature pack\r\n- [SvGrid vs AG Grid comparison page](https://svgrid.com/compare/ag-grid/)\r\n\r\n## Frequently asked questions\r\n\r\n### Is SvGrid a drop-in replacement for AG Grid in Svelte?\r\n\r\nNot a literal drop-in - there is no `ag-grid-svelte` shim to swap. But the\r\nconcepts map closely: column definitions, row models, sorting, filtering,\r\ngrouping, and an imperative API all have direct SvGrid equivalents, so most\r\nteams port a grid in 30 minutes to a day. It is a configuration translation,\r\nnot a rewrite.\r\n\r\n### What is the SvGrid equivalent of AG Grid Enterprise?\r\n\r\n`@svgrid/enterprise`. It adds Excel/PDF/CSV/TSV/HTML export, a printable view, pivot\r\ntables, data import, and AI helpers. It is licensed per developer\r\n($599 single-app / $999 multi-app), not per deployment, and the Community\r\npackage is MIT-licensed and free for commercial use.\r\n\r\n### Does SvGrid use Svelte 5 runes, or is it a wrapper?\r\n\r\nIt is Svelte-5-native. State is `$state` / `$derived` / `$effect` and cells\r\nrender through Svelte snippets - there is no React or Angular core underneath\r\nand no framework bridge to keep in sync.\r\n\r\n### Will my AG Grid bundle size shrink?\r\n\r\nAlmost always. SvGrid's full render component is ~77 KB gzipped (or ~2 KB\r\nfor the headless core) versus a much heavier AG Grid Community bundle, and you\r\nonly add `@svgrid/enterprise` features you actually use - so you ship a fraction of\r\nthe JavaScript.\r\n"
3486
3504
  },
3487
3505
  {
3488
3506
  "slug": "help/migrating-from-devextreme",
@@ -3500,7 +3518,7 @@ export const docs = [
3500
3518
  "slug": "help/migrating-from-gridjs",
3501
3519
  "path": "docs/help/migrating-from-gridjs.md",
3502
3520
  "title": "Migrating from Grid.js",
3503
- "markdown": "# Migrating from Grid.js\r\n\r\nGrid.js is a small vanilla-JS table for search, sort, and pagination\r\nthat you render into a DOM element. SvGrid does the same in a\r\nSvelte-5-native component and keeps going - virtualization, Excel-style\r\nfilters, editing, grouping - when you outgrow the basics.\r\n\r\n> Estimated effort: **30 min - 1 hour** per table.\r\n\r\n## Vocabulary cheat sheet\r\n\r\n| Grid.js | sv-grid |\r\n| ----------------------------------------- | ----------------------------------------- |\r\n| `new Grid({ ... }).render(el)` | `<SvGrid ... />` |\r\n| `columns: ['Name', 'Amount']` | `columns: [{ field, header }]` |\r\n| `columns: [{ name, formatter }]` | `{ header, cell: (c) => renderSnippet(...) }` |\r\n| `sort: true` | `rowSortingFeature` |\r\n| `search: true` | `api.setGlobalFilter(query)` / filter feature |\r\n| `pagination: { limit: 25 }` | `showPagination` (+ page size) |\r\n| `server: { url, then }` | `externalSort` / `externalFilter` + refetch |\r\n\r\n## Before / after\r\n\r\n```diff\r\n- import { Grid } from 'gridjs'\r\n- import 'gridjs/dist/theme/mermaid.css'\r\n-\r\n- new Grid({\r\n- data: rows,\r\n- columns: ['Name', 'Amount'],\r\n- sort: true, search: true,\r\n- pagination: { limit: 25 },\r\n- }).render(document.getElementById('grid'))\r\n\r\n+ <script lang=\"ts\">\r\n+ import {\r\n+ SvGrid, tableFeatures, rowSortingFeature, columnFilteringFeature,\r\n+ type ColumnDef,\r\n+ } from '@svgrid/grid'\r\n+ const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\r\n+ const columns: ColumnDef<typeof features, Row>[] = [\r\n+ { field: 'name', header: 'Name' },\r\n+ { field: 'amount', header: 'Amount', format: { type: 'currency', currency: 'USD' } },\r\n+ ]\r\n+ </script>\r\n+\r\n+ <SvGrid data={rows} columns={columns} features={features} showPagination />\r\n```\r\n\r\n## What you gain\r\n\r\n- **Virtualization** for large datasets (Grid.js renders the page).\r\n- **Excel-style filter menu**, **inline editing**, **grouping**, and\r\n **tree / master-detail** when you need them.\r\n- **Reactive data** - no `.render(el)` re-instantiation.\r\n- A **headless engine** and an **imperative API** plus `@svgrid/mcp`.\r\n\r\n## See also\r\n\r\n- [SvGrid vs Grid.js](https://svgrid.com/compare/gridjs/) - the side-by-side comparison\r\n- [Getting started](../getting-started.md) - a working grid in ~15 lines\r\n- [Server-side data](./server-side-data.md) - the external-data pattern\r\n\r\n## Frequently asked questions\r\n\r\n### When should I move from Grid.js to SvGrid?\r\n\r\nWhen you need more than search / sort / paginate - virtualization for big\r\ndatasets, inline editing, Excel-style filters, grouping, or tree data. For a\r\nsmall table, Grid.js stays lighter.\r\n\r\n### Is SvGrid MIT-licensed like Grid.js?\r\n\r\nYes. `@svgrid/grid` is MIT. Only the optional `@svgrid/enterprise` add-on is paid.\r\n\r\n### Does SvGrid support server-side data like Grid.js?\r\n\r\nYes. Set `externalSort` / `externalFilter` and refetch on the\r\n`onSortingChange` / `onFiltersChange` events.\r\n"
3521
+ "markdown": "# Migrating from Grid.js\n\nGrid.js is a small vanilla-JS table for search, sort, and pagination\nthat you render into a DOM element. SvGrid does the same in a\nSvelte-5-native component and keeps going - virtualization, Excel-style\nfilters, editing, grouping - when you outgrow the basics.\n\n> Estimated effort: **30 min - 1 hour** per table.\n\n## Vocabulary cheat sheet\n\n| Grid.js | sv-grid |\n| ----------------------------------------- | ----------------------------------------- |\n| `new Grid({ ... }).render(el)` | `<SvGrid ... />` |\n| `columns: ['Name', 'Amount']` | `columns: [{ field, header }]` |\n| `columns: [{ name, formatter }]` | `{ header, cell: (c) => renderSnippet(...) }` |\n| `sort: true` | `rowSortingFeature` |\n| `search: true` | `api.setState({ globalFilter: query })` / filter feature |\n| `pagination: { limit: 25 }` | `showPagination` (+ page size) |\n| `server: { url, then }` | `externalSort` / `externalFilter` + refetch |\n\n## Before / after\n\n```diff\n- import { Grid } from 'gridjs'\n- import 'gridjs/dist/theme/mermaid.css'\n-\n- new Grid({\n- data: rows,\n- columns: ['Name', 'Amount'],\n- sort: true, search: true,\n- pagination: { limit: 25 },\n- }).render(document.getElementById('grid'))\n\n+ <script lang=\"ts\">\n+ import {\n+ SvGrid, tableFeatures, rowSortingFeature, columnFilteringFeature,\n+ type ColumnDef,\n+ } from '@svgrid/grid'\n+ const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n+ const columns: ColumnDef<typeof features, Row>[] = [\n+ { field: 'name', header: 'Name' },\n+ { field: 'amount', header: 'Amount', format: { type: 'currency', currency: 'USD' } },\n+ ]\n+ </script>\n+\n+ <SvGrid data={rows} columns={columns} features={features} showPagination />\n```\n\n## What you gain\n\n- **Virtualization** for large datasets (Grid.js renders the page).\n- **Excel-style filter menu**, **inline editing**, **grouping**, and\n **tree / master-detail** when you need them.\n- **Reactive data** - no `.render(el)` re-instantiation.\n- A **headless engine** and an **imperative API** plus `@svgrid/mcp`.\n\n## See also\n\n- [SvGrid vs Grid.js](https://svgrid.com/compare/gridjs/) - the side-by-side comparison\n- [Getting started](../getting-started.md) - a working grid in ~15 lines\n- [Server-side data](./server-side-data.md) - the external-data pattern\n\n## Frequently asked questions\n\n### When should I move from Grid.js to SvGrid?\n\nWhen you need more than search / sort / paginate - virtualization for big\ndatasets, inline editing, Excel-style filters, grouping, or tree data. For a\nsmall table, Grid.js stays lighter.\n\n### Is SvGrid MIT-licensed like Grid.js?\n\nYes. `@svgrid/grid` is MIT. Only the optional `@svgrid/enterprise` add-on is paid.\n\n### Does SvGrid support server-side data like Grid.js?\n\nYes. Set `externalSort` / `externalFilter` and refetch on the\n`onSortingChange` / `onFiltersChange` events.\n"
3504
3522
  },
3505
3523
  {
3506
3524
  "slug": "help/migrating-from-handsontable",
@@ -3524,7 +3542,7 @@ export const docs = [
3524
3542
  "slug": "help/migrating-from-mui-x",
3525
3543
  "path": "docs/help/migrating-from-mui-x.md",
3526
3544
  "title": "Migrating from MUI X DataGrid",
3527
- "markdown": "# Migrating from MUI X DataGrid\r\n\r\nMUI X DataGrid is the most common starting point for teams already\r\non Material UI. It's a closed-source-Pro / open-source-Community\r\nsplit very similar to sv-grid's. The port is mostly mechanical.\r\n\r\n> Estimated effort: **1-3 hours** per grid, depending on how heavily\r\n> you've leant on `apiRef.current.*` calls.\r\n\r\n## Package map\r\n\r\n| MUI X | sv-grid |\r\n| ------------------------------------ | ---------------------------------------- |\r\n| `@mui/x-data-grid` | `@svgrid/grid` |\r\n| `@mui/x-data-grid-pro` | `@svgrid/enterprise` (export, import, pivot, AI) |\r\n| `@mui/x-data-grid-premium` | All of `@svgrid/enterprise` ships in one tier |\r\n\r\n## Imports\r\n\r\n```diff\r\n- import { DataGrid, GridColDef, GridRowsProp } from '@mui/x-data-grid'\r\n\r\n+ import { SvGrid, tableFeatures, rowSortingFeature, columnFilteringFeature,\r\n+ type ColumnDef } from '@svgrid/grid'\r\n```\r\n\r\n## Column defs\r\n\r\n```diff\r\n- const columns: GridColDef[] = [\r\n- { field: 'id', headerName: 'ID', width: 90 },\r\n- { field: 'lastName', headerName: 'Last', width: 150, editable: true },\r\n- { field: 'age', headerName: 'Age', type: 'number', width: 110, editable: true },\r\n- ]\r\n\r\n+ const columns: ColumnDef<typeof features, Row>[] = [\r\n+ { field: 'id', header: 'ID', width: 90 },\r\n+ { field: 'lastName', header: 'Last', width: 150 },\r\n+ { field: 'age', header: 'Age', width: 110, editorType: 'number' },\r\n+ ]\r\n```\r\n\r\n- `headerName` → `header`\r\n- `type: 'number'` → `editorType: 'number'`\r\n- `editable: true` → omit (every column is editable by default; set\r\n `editable: false` to OPT OUT)\r\n\r\n## Mounting\r\n\r\n```diff\r\n- <DataGrid rows={rows} columns={columns} pageSize={25} checkboxSelection />\r\n\r\n+ <SvGrid\r\n+ data={rows} columns={columns} features={features}\r\n+ showPagination={true} pageSize={25}\r\n+ selectionMode=\"row\" />\r\n```\r\n\r\n`tableFeatures({ rowSortingFeature, columnFilteringFeature,\r\nrowSelectionFeature })` registers what you'd implicitly get from\r\nMUI X.\r\n\r\n## `apiRef` translation\r\n\r\n| MUI X (`apiRef.current.*`) | sv-grid (`api.*`) |\r\n| -------------------------------------------- | ------------------------------------------ |\r\n| `setRows(rows)` | mutate the `$state` array you bound to `data` |\r\n| `updateRows([{id, ...patch}])` | `api.setCellValue(rowIndex, field, value)` |\r\n| `setSortModel([{field, sort}])` | `api.setSort(field, sort)` |\r\n| `setFilterModel({items: [...]})` | `api.setFilter(field, {operator, value})` |\r\n| `setPage(0)` / `setPageSize(50)` | Use the built-in pager; for headless control register `pageSize` prop |\r\n| `selectRow(id)` | Toggle the row checkbox via the wrapper's UI; programmatic select is `api.setRowSelection({id: true})` |\r\n| `getSelectedRows()` | `api.getDisplayedRows().filter((r) => ...)` |\r\n| `exportDataAsExcel()` | `api.exportData({ format: 'xlsx' })` (Enterprise) |\r\n| `setColumnVisibilityModel({field: false})` | `api.setColumnVisible('field', false)` |\r\n\r\n## Custom cells\r\n\r\n```diff\r\n- {\r\n- field: 'status',\r\n- renderCell: ({ row }) => <Chip label={row.status} color={row.status === 'active' ? 'success' : 'default'} />,\r\n- }\r\n\r\n+ {\r\n+ field: 'status',\r\n+ cell: (ctx) => renderSnippet(StatusChip, { status: ctx.row.original.status }),\r\n+ }\r\n```\r\n\r\n## Selection\r\n\r\n```diff\r\n- <DataGrid checkboxSelection\r\n- onRowSelectionModelChange={(ids) => setSelected(ids)} />\r\n\r\n+ <SvGrid\r\n+ {data} {columns} features={features}\r\n+ selectionMode=\"row\"\r\n+ onRowSelectionChange={(selection, rows) => setSelected(rows)} />\r\n```\r\n\r\n`selectionMode` values: `'row'` (the MUI X equivalent), `'cell'`\r\n(spreadsheet-style range), `'both'`, `'none'`.\r\n\r\n## Inline editing\r\n\r\nMUI X had `processRowUpdate(newRow, oldRow)` returning the new row.\r\nSv-grid's equivalent is `onCellValueChange`:\r\n\r\n```diff\r\n- <DataGrid processRowUpdate={async (newRow) => {\r\n- await api.savePatch(newRow)\r\n- return newRow\r\n- }} />\r\n\r\n+ <SvGrid\r\n+ onCellValueChange={async (e) => {\r\n+ await api.savePatch({ id: e.row.id, [e.columnId]: e.newValue })\r\n+ }}\r\n+ />\r\n```\r\n\r\nFor full-row editing (one Save button per row), see\r\n[Full-row editing](./editing/full-row.md).\r\n\r\n## Server-side data\r\n\r\nMUI X's `pagination + serverSideMode + filterMode='server'` maps to\r\nsv-grid's `externalSort + externalFilter`:\r\n\r\n```svelte\r\n<SvGrid\r\n data={rows} columns={columns} features={features}\r\n externalSort={true}\r\n externalFilter={true}\r\n onSortingChange={async (s) => { rows = await fetchPage({ sort: s }) }}\r\n onFiltersChange={async (f) => { rows = await fetchPage({ filters: f.columns }) }}\r\n/>\r\n```\r\n\r\n## Slots / customisation\r\n\r\nMUI X exposes a `slots` object. Sv-grid doesn't - instead, every\r\nvisual piece is a CSS custom property (`--sg-*`); see\r\n[design tokens](./tokens.md). Override at any DOM level:\r\n\r\n```css\r\n.grid-host { --sg-accent: #db2777; --sg-row-height: 40px; }\r\n```\r\n\r\nFor full theme presets (Ant, MUI, Fluent, Base Web, shadcn) see\r\n[demo 74](https://svgrid.com/demos/74-theme-integrations/).\r\n\r\n## What you get for free vs MUI X\r\n\r\n- **No Emotion / no Material theme dependency.** ~50 kB gzip total\r\n vs MUI X DataGrid Community's ~270 kB.\r\n- **All Enterprise features in one tier.** No DataGrid Pro vs Premium split.\r\n- **CSP-clean.** No `eval`.\r\n\r\n## What you give up\r\n\r\n- **The Material Design look out of the box.** Sv-grid ships\r\n unstyled-by-token; the [MUI preset in demo 74](https://svgrid.com/demos/74-theme-integrations/)\r\n is one drop-in.\r\n- **MUI form-field integration.** Bind directly to your own MUI\r\n inputs in custom cell components if you want them.\r\n\r\n## See also\r\n\r\n- [SvGrid vs MUI X DataGrid](https://svgrid.com/compare/mui-x-datagrid/) - the side-by-side comparison\r\n- [Migrating from AG Grid](./migrating-from-ag-grid.md)\r\n- [Migrating from TanStack Table](./migrating-from-tanstack-table.md)\r\n- [Design tokens](./tokens.md)\r\n\r\n## Frequently asked questions\r\n\r\n### How hard is it to migrate from MUI X DataGrid to SvGrid?\r\n\r\nMostly mechanical - typically 1-3 hours per grid, depending on how heavily you\r\nrelied on `apiRef.current.*`. The Community/Enterprise split mirrors MUI X's, so the\r\nlicensing mental model carries over directly.\r\n\r\n### Can SvGrid keep the Material Design look?\r\n\r\nYes. SvGrid ships unstyled-by-token and re-themes through `--sg-*` CSS\r\nvariables; the MUI preset in demo 74 is a drop-in starting point. You can also\r\nbind your own MUI inputs inside custom cell components.\r\n\r\n### Is SvGrid cheaper than MUI X Pro/Premium?\r\n\r\nSvGrid's Community tier is MIT and free for commercial use, and `@svgrid/enterprise`\r\nis priced per developer ($599 single-app / $999 multi-app) rather than\r\nper seat with Premium add-ons. Compare your team size and feature needs against\r\nthe [pricing page](https://svgrid.com/pricing/).\r\n"
3545
+ "markdown": "# Migrating from MUI X DataGrid\r\n\r\nMUI X DataGrid is the most common starting point for teams already\r\non Material UI. It's a closed-source-Pro / open-source-Community\r\nsplit very similar to sv-grid's. The port is mostly mechanical.\r\n\r\n> Estimated effort: **1-3 hours** per grid, depending on how heavily\r\n> you've leant on `apiRef.current.*` calls.\r\n\r\n## Package map\r\n\r\n| MUI X | sv-grid |\r\n| ------------------------------------ | ---------------------------------------- |\r\n| `@mui/x-data-grid` | `@svgrid/grid` |\r\n| `@mui/x-data-grid-pro` | `@svgrid/enterprise` (export, import, pivot, AI) |\r\n| `@mui/x-data-grid-premium` | All of `@svgrid/enterprise` ships in one tier |\r\n\r\n## Imports\r\n\r\n```diff\r\n- import { DataGrid, GridColDef, GridRowsProp } from '@mui/x-data-grid'\r\n\r\n+ import { SvGrid, tableFeatures, rowSortingFeature, columnFilteringFeature,\r\n+ type ColumnDef } from '@svgrid/grid'\r\n```\r\n\r\n## Column defs\r\n\r\n```diff\r\n- const columns: GridColDef[] = [\r\n- { field: 'id', headerName: 'ID', width: 90 },\r\n- { field: 'lastName', headerName: 'Last', width: 150, editable: true },\r\n- { field: 'age', headerName: 'Age', type: 'number', width: 110, editable: true },\r\n- ]\r\n\r\n+ const columns: ColumnDef<typeof features, Row>[] = [\r\n+ { field: 'id', header: 'ID', width: 90 },\r\n+ { field: 'lastName', header: 'Last', width: 150 },\r\n+ { field: 'age', header: 'Age', width: 110, editorType: 'number' },\r\n+ ]\r\n```\r\n\r\n- `headerName` → `header`\r\n- `type: 'number'` → `editorType: 'number'`\r\n- `editable: true` → omit (every column is editable by default; set\r\n `editable: false` to OPT OUT)\r\n\r\n## Mounting\r\n\r\n```diff\r\n- <DataGrid rows={rows} columns={columns} pageSize={25} checkboxSelection />\r\n\r\n+ <SvGrid\r\n+ data={rows} columns={columns} features={features}\r\n+ showPagination={true} pageSize={25}\r\n+ selectionMode=\"row\" />\r\n```\r\n\r\n`tableFeatures({ rowSortingFeature, columnFilteringFeature,\r\nrowSelectionFeature })` registers what you'd implicitly get from\r\nMUI X.\r\n\r\n## `apiRef` translation\r\n\r\n| MUI X (`apiRef.current.*`) | sv-grid (`api.*`) |\r\n| -------------------------------------------- | ------------------------------------------ |\r\n| `setRows(rows)` | mutate the `$state` array you bound to `data` |\r\n| `updateRows([{id, ...patch}])` | `api.setCellValue(rowIndex, field, value)` |\r\n| `setSortModel([{field, sort}])` | `api.setSort(field, sort)` |\r\n| `setFilterModel({items: [...]})` | `api.setFilter(field, {operator, value})` |\r\n| `setPage(0)` / `setPageSize(50)` | Use the built-in pager; for headless control register `pageSize` prop |\r\n| `selectRow(id)` | Toggle the row checkbox via the wrapper's UI; programmatic select is `api.selectRows([id])` (`api.toggleRowSelected(id)` to flip one) |\r\n| `getSelectedRows()` | `api.getSelectedRows()` (ids via `api.getSelectedRowIds()`) |\r\n| `exportDataAsExcel()` | `api.exportData({ format: 'xlsx' })` (Enterprise) |\r\n| `setColumnVisibilityModel({field: false})` | `api.setColumnVisible('field', false)` |\r\n\r\n## Custom cells\r\n\r\n```diff\r\n- {\r\n- field: 'status',\r\n- renderCell: ({ row }) => <Chip label={row.status} color={row.status === 'active' ? 'success' : 'default'} />,\r\n- }\r\n\r\n+ {\r\n+ field: 'status',\r\n+ cell: (ctx) => renderSnippet(StatusChip, { status: ctx.row.original.status }),\r\n+ }\r\n```\r\n\r\n## Selection\r\n\r\n```diff\r\n- <DataGrid checkboxSelection\r\n- onRowSelectionModelChange={(ids) => setSelected(ids)} />\r\n\r\n+ <SvGrid\r\n+ {data} {columns} features={features}\r\n+ selectionMode=\"row\"\r\n+ onRowSelectionChange={(selection, rows) => setSelected(rows)} />\r\n```\r\n\r\n`selectionMode` values: `'row'` (the MUI X equivalent), `'cell'`\r\n(spreadsheet-style range), `'both'`, `'none'`.\r\n\r\n## Inline editing\r\n\r\nMUI X had `processRowUpdate(newRow, oldRow)` returning the new row.\r\nSv-grid's equivalent is `onCellValueChange`:\r\n\r\n```diff\r\n- <DataGrid processRowUpdate={async (newRow) => {\r\n- await api.savePatch(newRow)\r\n- return newRow\r\n- }} />\r\n\r\n+ <SvGrid\r\n+ onCellValueChange={async (e) => {\r\n+ await api.savePatch({ id: e.row.id, [e.columnId]: e.newValue })\r\n+ }}\r\n+ />\r\n```\r\n\r\nFor full-row editing (one Save button per row), see\r\n[Full-row editing](./editing/full-row.md).\r\n\r\n## Server-side data\r\n\r\nMUI X's `pagination + serverSideMode + filterMode='server'` maps to\r\nsv-grid's `externalSort + externalFilter`:\r\n\r\n```svelte\r\n<SvGrid\r\n data={rows} columns={columns} features={features}\r\n externalSort={true}\r\n externalFilter={true}\r\n onSortingChange={async (s) => { rows = await fetchPage({ sort: s }) }}\r\n onFiltersChange={async (f) => { rows = await fetchPage({ filters: f.columns }) }}\r\n/>\r\n```\r\n\r\n## Slots / customisation\r\n\r\nMUI X exposes a `slots` object. Sv-grid doesn't - instead, every\r\nvisual piece is a CSS custom property (`--sg-*`); see\r\n[design tokens](./tokens.md). Override at any DOM level:\r\n\r\n```css\r\n.grid-host { --sg-accent: #db2777; --sg-radius: 10px; }\r\n```\r\n\r\nRow height is the exception: it is the `rowHeight` prop rather than a\r\ntoken, because the virtualizer needs it as a number.\r\n\r\nFor full theme presets (Ant, MUI, Fluent, Base Web, shadcn) see\r\n[demo 74](https://svgrid.com/demos/74-theme-integrations/).\r\n\r\n## What you get for free vs MUI X\r\n\r\n- **No Emotion / no Material theme dependency.** ~50 kB gzip total\r\n vs MUI X DataGrid Community's ~270 kB.\r\n- **All Enterprise features in one tier.** No DataGrid Pro vs Premium split.\r\n- **CSP-clean.** No `eval`.\r\n\r\n## What you give up\r\n\r\n- **The Material Design look out of the box.** Sv-grid ships\r\n unstyled-by-token; the [MUI preset in demo 74](https://svgrid.com/demos/74-theme-integrations/)\r\n is one drop-in.\r\n- **MUI form-field integration.** Bind directly to your own MUI\r\n inputs in custom cell components if you want them.\r\n\r\n## See also\r\n\r\n- [SvGrid vs MUI X DataGrid](https://svgrid.com/compare/mui-x-datagrid/) - the side-by-side comparison\r\n- [Migrating from AG Grid](./migrating-from-ag-grid.md)\r\n- [Migrating from TanStack Table](./migrating-from-tanstack-table.md)\r\n- [Design tokens](./tokens.md)\r\n\r\n## Frequently asked questions\r\n\r\n### How hard is it to migrate from MUI X DataGrid to SvGrid?\r\n\r\nMostly mechanical - typically 1-3 hours per grid, depending on how heavily you\r\nrelied on `apiRef.current.*`. The Community/Enterprise split mirrors MUI X's, so the\r\nlicensing mental model carries over directly.\r\n\r\n### Can SvGrid keep the Material Design look?\r\n\r\nYes. SvGrid ships unstyled-by-token and re-themes through `--sg-*` CSS\r\nvariables; the MUI preset in demo 74 is a drop-in starting point. You can also\r\nbind your own MUI inputs inside custom cell components.\r\n\r\n### Is SvGrid cheaper than MUI X Pro/Premium?\r\n\r\nSvGrid's Community tier is MIT and free for commercial use, and `@svgrid/enterprise`\r\nis priced per developer ($599 single-app / $999 multi-app) rather than\r\nper seat with Premium add-ons. Compare your team size and feature needs against\r\nthe [pricing page](https://svgrid.com/pricing/).\r\n"
3528
3546
  },
3529
3547
  {
3530
3548
  "slug": "help/migrating-from-primevue-datatable",
@@ -3554,7 +3572,7 @@ export const docs = [
3554
3572
  "slug": "help/migrating-from-svelte-headless-table",
3555
3573
  "path": "docs/help/migrating-from-svelte-headless-table.md",
3556
3574
  "title": "Migrating from svelte-headless-table",
3557
- "markdown": "# Migrating from svelte-headless-table\r\n\r\nsvelte-headless-table popularised the headless-table pattern in the\r\nSvelte ecosystem. It is built on Svelte 4 stores and plugins\r\n(`addSortBy`, `addColumnFilters`, `addPagination`, ...), and it leaves\r\nthe markup to you. SvGrid keeps the same headless idea but runs on\r\nSvelte 5 runes and ships a render component, so the port mostly\r\n*removes* the table you used to hand-author.\r\n\r\n> Estimated effort: **1-3 hours** per grid. Most of the time is deleting\r\n> the `<table>` markup and `Subscribe` blocks you no longer need.\r\n\r\n## Vocabulary cheat sheet\r\n\r\n| svelte-headless-table | sv-grid |\r\n| ----------------------------------------- | ----------------------------------------- |\r\n| `createTable(data, plugins)` | `createSvGrid({...})` or `<SvGrid>` |\r\n| `table.createColumns((t) => [...])` | `columns: ColumnDef[]` |\r\n| `t.column({ accessor: 'x', header })` | `{ field: 'x', header }` |\r\n| `t.column({ accessor: (r) => ... })` | `{ id, fieldFn: (r) => ... }` |\r\n| `t.group({ header, columns })` | `{ header, columns: [...] }` (column group) |\r\n| `addSortBy()` | `rowSortingFeature` |\r\n| `addColumnFilters()` / `addTableFilter()` | `columnFilteringFeature` |\r\n| `addPagination()` | Built in; toggle `showPagination` |\r\n| `addExpandedRows()` / `addSubRows()` | `rowExpandingFeature` |\r\n| `addGroupBy()` | `columnGroupingFeature` + `api.setGroupBy()` |\r\n| `addSelectedRows()` | `rowSelectionFeature` |\r\n| `addDataExport()` | `@svgrid/enterprise` export pack |\r\n| `createViewModel(columns)` + `Subscribe` | `<SvGrid>` (no view model to wire) |\r\n| `pluginStates.sort.sortKeys` | `api.setSort(id, dir)` / `onSortingChange` |\r\n\r\n## Before / after\r\n\r\n```diff\r\n- <script>\r\n- import { createTable } from 'svelte-headless-table'\r\n- import { addSortBy, addColumnFilters, addPagination } from 'svelte-headless-table/plugins'\r\n- import { readable } from 'svelte/store'\r\n-\r\n- const table = createTable(readable(data), {\r\n- sort: addSortBy(), filter: addColumnFilters(), page: addPagination(),\r\n- })\r\n- const columns = table.createColumns((t) => [\r\n- t.column({ accessor: 'name', header: 'Name' }),\r\n- t.column({ accessor: 'amount', header: 'Amount' }),\r\n- ])\r\n- const { headerRows, rows, tableAttrs, tableBodyAttrs } = table.createViewModel(columns)\r\n- </script>\r\n-\r\n- <table {...$tableAttrs}>\r\n- <thead> ...Subscribe over headerRows... </thead>\r\n- <tbody {...$tableBodyAttrs}> ...Subscribe over rows... </tbody>\r\n- </table>\r\n\r\n+ <script lang=\"ts\">\r\n+ import {\r\n+ SvGrid, tableFeatures, rowSortingFeature, columnFilteringFeature,\r\n+ type ColumnDef,\r\n+ } from '@svgrid/grid'\r\n+\r\n+ const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\r\n+ const columns: ColumnDef<typeof features, Row>[] = [\r\n+ { field: 'name', header: 'Name' },\r\n+ { field: 'amount', header: 'Amount', format: { type: 'currency', currency: 'USD' } },\r\n+ ]\r\n+ </script>\r\n+\r\n+ <SvGrid data={rows} columns={columns} features={features} showPagination />\r\n```\r\n\r\n## What you get for free\r\n\r\n- **The renderer.** No `createViewModel`, no `Subscribe`, no hand-built\r\n `<table>`. SvGrid ships virtualization, sticky headers, column\r\n resize, keyboard nav, and ARIA.\r\n- **Excel-style filter menu** and **cell-range selection + TSV copy**,\r\n which are BYO in svelte-headless-table.\r\n- **Inline editing** with typed editors and validation hooks.\r\n- **Enterprise features** - export, import, pivot, AI - in one paid add-on.\r\n\r\n## What changes\r\n\r\n- **Stores → runes.** Reactive data is `$state` / a plain array, not a\r\n Svelte store you pass to `createTable`.\r\n- **Plugins → features.** Register `rowSortingFeature` etc. in\r\n `tableFeatures({...})` instead of `addSortBy()` in the plugin bag.\r\n- **View model → component.** You stop owning the markup; style through\r\n `--sg-*` tokens (and Tailwind) instead of `tableAttrs`.\r\n\r\n## See also\r\n\r\n- [SvGrid vs svelte-headless-table](https://svgrid.com/compare/svelte-headless-table/) - the side-by-side comparison\r\n- [Migrating from TanStack Table](./migrating-from-tanstack-table.md) - sibling headless guide\r\n- [Why headless?](../why-headless.md) - the design rationale\r\n- [Architecture](./architecture.md) - the engine + render-component split\r\n\r\n## Frequently asked questions\r\n\r\n### How hard is it to move from svelte-headless-table to SvGrid?\r\n\r\nUsually 1-3 hours per grid. The plugin-to-feature mapping is almost one-to-one;\r\nmost of the work is deleting the `createViewModel` + `Subscribe` + `<table>`\r\nmarkup, because SvGrid renders that for you.\r\n\r\n### Does SvGrid use Svelte 5 runes instead of stores?\r\n\r\nYes. svelte-headless-table is built on Svelte 4 stores; SvGrid is Svelte-5\r\nnative (`$state` / `$derived` / `$effect`) with snippets for custom cells.\r\n\r\n### Is SvGrid still headless like svelte-headless-table?\r\n\r\nYes - `createSvGrid` plus the row-model factories is a headless engine you can\r\ndrive with your own markup. The difference is that SvGrid *also* ships a\r\nbatteries-included `<SvGrid>` component so you usually do not have to.\r\n"
3575
+ "markdown": "# Migrating from svelte-headless-table\r\n\r\nsvelte-headless-table popularised the headless-table pattern in the\r\nSvelte ecosystem. It is built on Svelte 4 stores and plugins\r\n(`addSortBy`, `addColumnFilters`, `addPagination`, ...), and it leaves\r\nthe markup to you. SvGrid keeps the same headless idea but runs on\r\nSvelte 5 runes and ships a render component, so the port mostly\r\n*removes* the table you used to hand-author.\r\n\r\n> Estimated effort: **1-3 hours** per grid. Most of the time is deleting\r\n> the `<table>` markup and `Subscribe` blocks you no longer need.\r\n\r\n## Know your options first\r\n\r\nThe last svelte-headless-table release was 0.18.3 in October 2024 and it\r\ndeclares `svelte@^4`, so a Svelte 5 upgrade forces a decision. There are three\r\nhonest answers and you should know all of them:\r\n\r\n1. **`@humanspeak/svelte-headless-table`** - a maintained fork on Svelte 5 with\r\n the same API. Changing one package name is the cheapest path by a wide\r\n margin. If your table works and you only need Svelte 5, do that.\r\n2. **TanStack Table v9** - shipped a Svelte 5 adapter in August 2026. Still\r\n headless-only, so you keep writing and maintaining the markup.\r\n3. **SvGrid** - this page. A different trade: you delete the markup and take a\r\n renderer instead.\r\n\r\nPick SvGrid when the markup is the part you are tired of. Either way, note that\r\nSvelte 5 removed `let:` slot props, so the `Subscribe` blocks below do not\r\nsurvive the upgrade unchanged no matter which option you choose.\r\n\r\n## Run the codemod\r\n\r\n```bash\r\nnpx @svgrid/migrate # preview the result\r\nnpx @svgrid/migrate src --write # apply it\r\n```\r\n\r\nIt translates column definitions and plugin config, deletes the\r\n`Subscribe`/`Render` scaffolding, and reports anything it cannot map rather than\r\ndropping it silently. It previews by default. See\r\n[`@svgrid/migrate`](https://www.npmjs.com/package/@svgrid/migrate) for the full\r\nmapping and its limits.\r\n\r\n## Vocabulary cheat sheet\r\n\r\n| svelte-headless-table | sv-grid |\r\n| ----------------------------------------- | ----------------------------------------- |\r\n| `createTable(data, plugins)` | `createSvGrid({...})` or `<SvGrid>` |\r\n| `table.createColumns((t) => [...])` | `columns: ColumnDef[]` |\r\n| `t.column({ accessor: 'x', header })` | `{ field: 'x', header }` |\r\n| `t.column({ accessor: (r) => ... })` | `{ id, fieldFn: (r) => ... }` |\r\n| `t.group({ header, columns })` | `{ header, columns: [...] }` (column group) |\r\n| `addSortBy()` | `rowSortingFeature` |\r\n| `addColumnFilters()` / `addTableFilter()` | `columnFilteringFeature` |\r\n| `addPagination()` | Built in; toggle `showPagination` |\r\n| `addExpandedRows()` / `addSubRows()` | `rowExpandingFeature` |\r\n| `addGroupBy()` | `columnGroupingFeature` + `api.setGroupBy()` |\r\n| `addSelectedRows()` | `rowSelectionFeature` |\r\n| `addDataExport()` | `@svgrid/enterprise` export pack |\r\n| `createViewModel(columns)` + `Subscribe` | `<SvGrid>` (no view model to wire) |\r\n| `pluginStates.sort.sortKeys` | `api.setSort(id, dir)` / `onSortingChange` |\r\n\r\n### The shortcut form\r\n\r\nThat table maps plugins onto the headless *features*, which is what you want if\r\nyou keep driving the engine yourself. If you are moving to the `<SvGrid>`\r\ncomponent, every capability also has a boolean prop, and that is what the\r\ncodemod emits:\r\n\r\n| Plugin | `<SvGrid>` prop |\r\n| --- | --- |\r\n| `addSortBy()` | `sortable` |\r\n| `addTableFilter()` | `filterable showGlobalFilter` |\r\n| `addColumnFilters()` | `filterable showColumnFilters` |\r\n| `addPagination({ initialPageSize: 25 })` | `pageable pageSize={25}` |\r\n| `addSelectedRows()` | `showRowSelection` |\r\n| `addGroupBy()` | `groupable` |\r\n| `addSubRows()` | `treeData` |\r\n| `addColumnOrder()` | `enableColumnReorder` |\r\n| `addResizedColumns()` | nothing - resizing is built in |\r\n| `addHiddenColumns()` | `visible: false` on the column |\r\n| `addExpandedRows()` | `treeData`, or `isDetailRow` + `renderDetailRow` |\r\n| `addGridLayout()` | nothing - SvGrid owns its layout |\r\n\r\nEvery capability is off by default and turned on by its prop; there is no\r\nplugin registration step.\r\n\r\n## Before / after\r\n\r\n```diff\r\n- <script>\r\n- import { createTable } from 'svelte-headless-table'\r\n- import { addSortBy, addColumnFilters, addPagination } from 'svelte-headless-table/plugins'\r\n- import { readable } from 'svelte/store'\r\n-\r\n- const table = createTable(readable(data), {\r\n- sort: addSortBy(), filter: addColumnFilters(), page: addPagination(),\r\n- })\r\n- const columns = table.createColumns((t) => [\r\n- t.column({ accessor: 'name', header: 'Name' }),\r\n- t.column({ accessor: 'amount', header: 'Amount' }),\r\n- ])\r\n- const { headerRows, rows, tableAttrs, tableBodyAttrs } = table.createViewModel(columns)\r\n- </script>\r\n-\r\n- <table {...$tableAttrs}>\r\n- <thead> ...Subscribe over headerRows... </thead>\r\n- <tbody {...$tableBodyAttrs}> ...Subscribe over rows... </tbody>\r\n- </table>\r\n\r\n+ <script lang=\"ts\">\r\n+ import {\r\n+ SvGrid, tableFeatures, rowSortingFeature, columnFilteringFeature,\r\n+ type ColumnDef,\r\n+ } from '@svgrid/grid'\r\n+\r\n+ const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\r\n+ const columns: ColumnDef<typeof features, Row>[] = [\r\n+ { field: 'name', header: 'Name' },\r\n+ { field: 'amount', header: 'Amount', format: { type: 'currency', currency: 'USD' } },\r\n+ ]\r\n+ </script>\r\n+\r\n+ <SvGrid data={rows} columns={columns} features={features} showPagination />\r\n```\r\n\r\nType the column array against your row type. `GridColumns<(typeof data)[number]>`\r\nis the shortest form; a bare `GridColumns` widens the row to\r\n`Record<string, unknown>` and stops checking `field` against your real keys.\r\n\r\n## What you get for free\r\n\r\n- **The renderer.** No `createViewModel`, no `Subscribe`, no hand-built\r\n `<table>`. SvGrid ships virtualization, sticky headers, column\r\n resize, keyboard nav, and ARIA.\r\n- **Excel-style filter menu** and **cell-range selection + TSV copy**,\r\n which are BYO in svelte-headless-table.\r\n- **Inline editing** with typed editors and validation hooks.\r\n- **Enterprise features** - export, import, pivot, AI - in one paid add-on.\r\n\r\n## What changes\r\n\r\n- **Stores → runes.** Reactive data is `$state` / a plain array, not a\r\n Svelte store you pass to `createTable`. If your rows came from a `derived`\r\n store, read it with `$` at the call site or move it to `$derived`.\r\n- **Plugins → features.** Register `rowSortingFeature` etc. in\r\n `tableFeatures({...})` instead of `addSortBy()` in the plugin bag.\r\n- **View model → component.** You stop owning the markup; style through\r\n `--sg-*` tokens (and Tailwind) instead of `tableAttrs`.\r\n\r\n## What does not come across\r\n\r\n- **Custom `cell` renderers.** `createRender(MyComponent, props)` has no direct\r\n equivalent; SvGrid uses a `cell` snippet. The codemod preserves yours as a\r\n `// TODO port:` comment.\r\n- **Your own plugins.** There is no plugin system to port them into.\r\n- **`tableAttrs` / `attrs()` spreading.** The component owns its attributes.\r\n\r\n## When not to migrate\r\n\r\n- You need control of the exact DOM. Use the fork, or SvGrid's\r\n [headless core](../why-headless.md) at `@svgrid/grid/core`.\r\n- Your table is a handful of rows with no interaction. A plain `{#each}` is\r\n less code than either library.\r\n- You depend on custom plugins. That is a rewrite, not a migration.\r\n- You only need Svelte 5 support. Use the fork.\r\n\r\n## See also\r\n\r\n- [SvGrid vs svelte-headless-table](https://svgrid.com/compare/svelte-headless-table/) - the side-by-side comparison\r\n- [Migrating from TanStack Table](./migrating-from-tanstack-table.md) - sibling headless guide\r\n- [Why headless?](../why-headless.md) - the design rationale\r\n- [Architecture](./architecture.md) - the engine + render-component split\r\n\r\n## Frequently asked questions\r\n\r\n### How hard is it to move from svelte-headless-table to SvGrid?\r\n\r\nUsually 1-3 hours per grid, and `npx @svgrid/migrate` does the mechanical part.\r\nThe plugin-to-feature mapping is almost one-to-one; most of the work is deleting\r\nthe `createViewModel` + `Subscribe` + `<table>` markup, because SvGrid renders\r\nthat for you.\r\n\r\n### Does SvGrid use Svelte 5 runes instead of stores?\r\n\r\nYes. svelte-headless-table is built on Svelte 4 stores; SvGrid is Svelte-5\r\nnative (`$state` / `$derived` / `$effect`) with snippets for custom cells.\r\n\r\n### Is SvGrid still headless like svelte-headless-table?\r\n\r\nYes - `createSvGrid` plus the row-model factories is a headless engine you can\r\ndrive with your own markup, importable on its own from `@svgrid/grid/core`. The\r\ndifference is that SvGrid *also* ships a batteries-included `<SvGrid>` component\r\nso you usually do not have to.\r\n\r\n### Is svelte-headless-table still maintained?\r\n\r\nThe original has not published since 0.18.3 in October 2024 and targets Svelte\r\n4. A community fork, `@humanspeak/svelte-headless-table`, is maintained and runs\r\non Svelte 5 with the same API, which is the lowest-effort option if you are\r\nhappy with the library and only need Svelte 5.\r\n"
3558
3576
  },
3559
3577
  {
3560
3578
  "slug": "help/migrating-from-syncfusion",
@@ -3584,19 +3602,19 @@ export const docs = [
3584
3602
  "slug": "help/migrating-from-vincjo-datatables",
3585
3603
  "path": "docs/help/migrating-from-vincjo-datatables.md",
3586
3604
  "title": "Migrating from @vincjo/datatables",
3587
- "markdown": "# Migrating from @vincjo/datatables\r\n\r\n`@vincjo/datatables` is a small, ergonomic Svelte datatable helper: you\r\nwrap your data in a handler and it gives you reactive sorting,\r\nfiltering, and pagination while you keep your own table markup. SvGrid\r\ncovers the same job and then keeps going - virtualization, an\r\nExcel-style filter menu, inline editing, and a render component - so the\r\nport is mostly about deciding how much markup you want to keep.\r\n\r\n> Estimated effort: **30 min - 2 hours** per table. A simple list is a\r\n> 30-minute swap; a feature-heavy one trends toward two hours.\r\n\r\n## Vocabulary cheat sheet\r\n\r\n| @vincjo/datatables | sv-grid |\r\n| ------------------------------------------ | ----------------------------------------- |\r\n| `new TableHandler(data, { rowsPerPage })` | `createSvGrid({...})` or `<SvGrid>` |\r\n| `<ThSort {table} field=\"name\">` | `rowSortingFeature` (header sort built in) |\r\n| `<ThFilter {table} field=\"name\">` | `columnFilteringFeature` |\r\n| `table.global.set(query)` (search) | `api.setGlobalFilter(query)` |\r\n| `<Pagination {table} />` / `<RowCount>` | Built in; toggle `showPagination` |\r\n| `table.rows` (current page rows) | `api.getDisplayedRows()` |\r\n| Server mode (`table.load(...)`) | `externalSort` / `externalFilter` + refetch |\r\n| Your own `<table>` + `{#each table.rows}` | `<SvGrid>` render component |\r\n\r\n## Before / after\r\n\r\n```diff\r\n- <script>\r\n- import { TableHandler, ThSort, ThFilter, Pagination } from '@vincjo/datatables'\r\n- const table = new TableHandler(rows, { rowsPerPage: 25 })\r\n- </script>\r\n-\r\n- <table>\r\n- <thead><tr>\r\n- <ThSort {table} field=\"name\">Name</ThSort>\r\n- <ThSort {table} field=\"amount\">Amount</ThSort>\r\n- </tr></thead>\r\n- <tbody>\r\n- {#each table.rows as row}\r\n- <tr><td>{row.name}</td><td>{row.amount}</td></tr>\r\n- {/each}\r\n- </tbody>\r\n- </table>\r\n- <Pagination {table} />\r\n\r\n+ <script lang=\"ts\">\r\n+ import {\r\n+ SvGrid, tableFeatures, rowSortingFeature, columnFilteringFeature,\r\n+ type ColumnDef,\r\n+ } from '@svgrid/grid'\r\n+\r\n+ const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\r\n+ const columns: ColumnDef<typeof features, Row>[] = [\r\n+ { field: 'name', header: 'Name' },\r\n+ { field: 'amount', header: 'Amount', format: { type: 'currency', currency: 'USD' } },\r\n+ ]\r\n+ </script>\r\n+\r\n+ <SvGrid data={rows} columns={columns} features={features} showPagination />\r\n```\r\n\r\n## Server-side data\r\n\r\n```diff\r\n- // @vincjo/datatables: drive a server with the handler's load hook\r\n- const table = new TableHandler(rows, { rowsPerPage: 25 })\r\n- table.load((state) => fetchRows(state)) // sort/filter/page sent to your API\r\n\r\n+ <SvGrid\r\n+ data={rows} columns={columns} features={features}\r\n+ externalSort={true} externalFilter={true}\r\n+ onSortingChange={(clauses) => refetch({ sort: clauses })}\r\n+ onFiltersChange={(f) => refetch({ filters: f.columns })}\r\n+ />\r\n```\r\n\r\n## What you get for free\r\n\r\n- **A render component** with virtualization - you stop authoring the\r\n `<table>` and the `{#each}`.\r\n- **Excel-style filter menu**, **cell-range selection + copy**, and\r\n **inline editing** with typed editors.\r\n- **Grouping, tree data, and master/detail** when the table grows up.\r\n- **Enterprise features** - export, import, pivot, AI.\r\n\r\n## What you give up\r\n\r\n- **The tiny footprint.** If all you ever need is sort / filter /\r\n paginate over a small list, `@vincjo/datatables` stays smaller.\r\n- **Total markup control.** SvGrid renders the table; you theme it with\r\n `--sg-*` tokens and Tailwind rather than writing every `<td>`.\r\n\r\n## See also\r\n\r\n- [SvGrid vs @vincjo/datatables](https://svgrid.com/compare/vincjo-datatables/) - the side-by-side comparison\r\n- [Migrating from svelte-headless-table](./migrating-from-svelte-headless-table.md) - sibling Svelte guide\r\n- [Why headless?](../why-headless.md) - keep your own markup if you want to\r\n\r\n## Frequently asked questions\r\n\r\n### Should I move from @vincjo/datatables to SvGrid?\r\n\r\nMove when a hand-rolled table stops being enough - when you need virtualization\r\nfor large datasets, an Excel-style filter menu, inline editing, grouping, or\r\ntree data. For a small sort/filter/paginate list, `@vincjo/datatables` is a fine,\r\nlighter choice.\r\n\r\n### Is SvGrid also MIT-licensed like @vincjo/datatables?\r\n\r\nYes. `@svgrid/grid` is MIT, like `@vincjo/datatables`. SvGrid adds an\r\noptional paid `@svgrid/enterprise` pack (export, import, pivot, AI).\r\n\r\n### Can I keep my own table markup after switching?\r\n\r\nYes. SvGrid has a headless core (`createSvGrid` + row-model factories) you can\r\ndrive with your own markup, the same way `@vincjo/datatables` lets you. Most\r\nteams use the `<SvGrid>` component instead because it removes the boilerplate.\r\n"
3605
+ "markdown": "# Migrating from @vincjo/datatables\n\n`@vincjo/datatables` is a small, ergonomic Svelte datatable helper: you\nwrap your data in a handler and it gives you reactive sorting,\nfiltering, and pagination while you keep your own table markup. SvGrid\ncovers the same job and then keeps going - virtualization, an\nExcel-style filter menu, inline editing, and a render component - so the\nport is mostly about deciding how much markup you want to keep.\n\n> Estimated effort: **30 min - 2 hours** per table. A simple list is a\n> 30-minute swap; a feature-heavy one trends toward two hours.\n\n## Vocabulary cheat sheet\n\n| @vincjo/datatables | sv-grid |\n| ------------------------------------------ | ----------------------------------------- |\n| `new TableHandler(data, { rowsPerPage })` | `createSvGrid({...})` or `<SvGrid>` |\n| `<ThSort {table} field=\"name\">` | `rowSortingFeature` (header sort built in) |\n| `<ThFilter {table} field=\"name\">` | `columnFilteringFeature` |\n| `table.global.set(query)` (search) | `api.setState({ globalFilter: query })` |\n| `<Pagination {table} />` / `<RowCount>` | Built in; toggle `showPagination` |\n| `table.rows` (current page rows) | `api.getDisplayedRows()` |\n| Server mode (`table.load(...)`) | `externalSort` / `externalFilter` + refetch |\n| Your own `<table>` + `{#each table.rows}` | `<SvGrid>` render component |\n\n## Before / after\n\n```diff\n- <script>\n- import { TableHandler, ThSort, ThFilter, Pagination } from '@vincjo/datatables'\n- const table = new TableHandler(rows, { rowsPerPage: 25 })\n- </script>\n-\n- <table>\n- <thead><tr>\n- <ThSort {table} field=\"name\">Name</ThSort>\n- <ThSort {table} field=\"amount\">Amount</ThSort>\n- </tr></thead>\n- <tbody>\n- {#each table.rows as row}\n- <tr><td>{row.name}</td><td>{row.amount}</td></tr>\n- {/each}\n- </tbody>\n- </table>\n- <Pagination {table} />\n\n+ <script lang=\"ts\">\n+ import {\n+ SvGrid, tableFeatures, rowSortingFeature, columnFilteringFeature,\n+ type ColumnDef,\n+ } from '@svgrid/grid'\n+\n+ const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n+ const columns: ColumnDef<typeof features, Row>[] = [\n+ { field: 'name', header: 'Name' },\n+ { field: 'amount', header: 'Amount', format: { type: 'currency', currency: 'USD' } },\n+ ]\n+ </script>\n+\n+ <SvGrid data={rows} columns={columns} features={features} showPagination />\n```\n\n## Server-side data\n\n```diff\n- // @vincjo/datatables: drive a server with the handler's load hook\n- const table = new TableHandler(rows, { rowsPerPage: 25 })\n- table.load((state) => fetchRows(state)) // sort/filter/page sent to your API\n\n+ <SvGrid\n+ data={rows} columns={columns} features={features}\n+ externalSort={true} externalFilter={true}\n+ onSortingChange={(clauses) => refetch({ sort: clauses })}\n+ onFiltersChange={(f) => refetch({ filters: f.columns })}\n+ />\n```\n\n## What you get for free\n\n- **A render component** with virtualization - you stop authoring the\n `<table>` and the `{#each}`.\n- **Excel-style filter menu**, **cell-range selection + copy**, and\n **inline editing** with typed editors.\n- **Grouping, tree data, and master/detail** when the table grows up.\n- **Enterprise features** - export, import, pivot, AI.\n\n## What you give up\n\n- **The tiny footprint.** If all you ever need is sort / filter /\n paginate over a small list, `@vincjo/datatables` stays smaller.\n- **Total markup control.** SvGrid renders the table; you theme it with\n `--sg-*` tokens and Tailwind rather than writing every `<td>`.\n\n## See also\n\n- [SvGrid vs @vincjo/datatables](https://svgrid.com/compare/vincjo-datatables/) - the side-by-side comparison\n- [Migrating from svelte-headless-table](./migrating-from-svelte-headless-table.md) - sibling Svelte guide\n- [Why headless?](../why-headless.md) - keep your own markup if you want to\n\n## Frequently asked questions\n\n### Should I move from @vincjo/datatables to SvGrid?\n\nMove when a hand-rolled table stops being enough - when you need virtualization\nfor large datasets, an Excel-style filter menu, inline editing, grouping, or\ntree data. For a small sort/filter/paginate list, `@vincjo/datatables` is a fine,\nlighter choice.\n\n### Is SvGrid also MIT-licensed like @vincjo/datatables?\n\nYes. `@svgrid/grid` is MIT, like `@vincjo/datatables`. SvGrid adds an\noptional paid `@svgrid/enterprise` pack (export, import, pivot, AI).\n\n### Can I keep my own table markup after switching?\n\nYes. SvGrid has a headless core (`createSvGrid` + row-model factories) you can\ndrive with your own markup, the same way `@vincjo/datatables` lets you. Most\nteams use the `<SvGrid>` component instead because it removes the boilerplate.\n"
3588
3606
  },
3589
3607
  {
3590
3608
  "slug": "help/missing-features",
3591
3609
  "path": "docs/help/missing-features.md",
3592
3610
  "title": "Missing features",
3593
- "markdown": "# Missing features\r\n\r\nAn honest accounting of what is **not yet built**, audited against the shipped\r\ndemo catalog. Most of what used to live here has shipped; the remaining gaps\r\nare small and clearly marked. Each entry has a rough effort estimate (S / M / L).\r\n\r\nShipped items are struck through with the demo or API that covers them, so you\r\ncan see both the trajectory and the (short) list of real gaps.\r\n\r\n## Columns\r\n\r\n| Gap | Status | Effort |\r\n| --- | ------ | ------ |\r\n| ~~`getRowId` prop~~ | **shipped** | ✓ |\r\n| ~~`cellClass(ctx)` / `rowClass(ctx)` callbacks~~ | **shipped** | ✓ |\r\n| ~~`getColumnWidths()` / `setColumnWidth()`~~ | **shipped** | ✓ |\r\n| ~~`setColumnPinning()` / `getColumnPinning()`~~ | **shipped** | ✓ |\r\n| ~~Header drag-to-reorder~~ | **shipped** - `enableColumnReorder`; demo `109-column-reorder-engine` | ✓ |\r\n| ~~Per-column disable sort / filter~~ | **shipped** - `sortable` / `filterable` on `ColumnDef` | ✓ |\r\n| ~~Column spanning~~ | **shipped** - cell merging via `MergeSpec` + `spreadsheetLayout` (demo `170`), **plus** declarative value-driven `colSpan` / `rowSpan` via `spansToMerges` | ✓ |\r\n\r\n## Rows\r\n\r\n| Gap | Status | Effort |\r\n| --- | ------ | ------ |\r\n| ~~Row pinning (top / bottom)~~ | **shipped** - `pinnedTopRows` / `pinnedBottomRows`; demos `107-pinned-rows`, `108-pinned-rows-engine` | ✓ |\r\n| ~~Row spanning (merged cells across rows)~~ | **shipped as cell merging** - `rowspan` in `MergeSpec`; demo `170-cell-merging` | ✓ |\r\n| ~~Full-width / detail row API~~ | **shipped** - `isDetailRow`; demo `106-detail-rows` | ✓ |\r\n| ~~Variable row height with `<SvGrid>`~~ | **shipped** - `rowHeight` accepts `(rowIndex) => px` | ✓ |\r\n| ~~Auto row height (measure content)~~ | **shipped** - `autoRowHeight` wraps cell text and measures each row, virtualization included | ✓ |\r\n| ~~`api.getDisplayedRows()`~~ | **shipped** | ✓ |\r\n| ~~Client-side tree data (hierarchical rows)~~ | **shipped** - `treeData` nests by parent id, `flattenTreeData` converts nested children; treegrid role + arrow-key expand; demo `426-tree-data` | ✓ |\r\n| ~~Built-in row dragging~~ | **shipped** - `rowDragManaged` reorders in-grid and moves rows **grid-to-grid** via a shared `rowDragGroup`; `onRowDragEnd` on the receiver; demos `105-row-reorder` (custom) + `180-row-dragging` (managed) | ✓ |\r\n\r\n## Cells\r\n\r\n| Gap | Status | Effort |\r\n| --- | ------ | ------ |\r\n| ~~Built-in tooltip API on `ColumnDef`~~ | **shipped** - `tooltip`; demo `85-tooltips-and-notes` | ✓ |\r\n| ~~Formula language / formula editor~~ | **shipped** - in-grid engine (demo `83-spreadsheet-formulas`), HyperFormula adapter (demo `173-hyperformula`), xlsx formulas (`101`, `119`) | ✓ |\r\n| ~~Find-in-grid~~ | **shipped** - Ctrl+F; demo `87-find-in-grid` | ✓ |\r\n| ~~Notes~~ | **shipped** - `notes` prop + cell comments; demos `85-tooltips-and-notes`, `91-cell-comments` | ✓ |\r\n| ~~Built-in cell flash / animated change highlight~~ | **shipped** - `cellFlash` on `ColumnDef` | ✓ |\r\n\r\n## Export / Print\r\n\r\n| Gap | Status | Effort |\r\n| --- | ------ | ------ |\r\n| ~~Excel / xlsx, PDF, CSV / TSV / HTML export, Print~~ | **shipped** in `@svgrid/enterprise` - demos `21`, `56`-`59`, `93`, `101`, `119`, `126`, `127` | ✓ |\r\n\r\n## Filtering\r\n\r\n| Gap | Status | Effort |\r\n| --- | ------ | ------ |\r\n| ~~`between` operator in the column menu~~ | **shipped** - demo `64-filter-between-operator` | ✓ |\r\n| ~~Set filter (tree-list, async, Excel-mode)~~ | **shipped** - demo `111-set-filter-advanced` | ✓ |\r\n| ~~Locale-aware text filtering~~ | **shipped** - demo `110-locale-aware-filter` | ✓ |\r\n| ~~`clearAllFilters()` / `getFilters()`~~ | **shipped** | ✓ |\r\n| ~~Floating filters (per-operator)~~ | **shipped** - filter row honours every operator per column with typed inputs + inline `between`; demo `179` | ✓ |\r\n| ~~Multi-condition filter within one column (AND / OR)~~ | **shipped** - two conditions per column via the funnel or `api.setFilter`; demo `178` | ✓ |\r\n\r\n## Editing\r\n\r\n| Gap | Status | Effort |\r\n| --- | ------ | ------ |\r\n| ~~`cellEditor` slot for custom inline editors~~ | **shipped** - demos `84-editor-types`, `66-custom-cell-editors` | ✓ |\r\n| ~~Built-in select & rich-select editors~~ | **shipped** - `editorType: 'list' / 'rich-select'`; demo `84-editor-types` | ✓ |\r\n| ~~Built-in large-text (textarea) editor~~ | **shipped** - demo `84-editor-types` | ✓ |\r\n| ~~Per-column `validate()`~~ | **shipped** - demos `24-validation`, `103-async-validation` | ✓ |\r\n| ~~Built-in undo / redo stack~~ | **shipped** - `api.undo()` / `redo()`; demo `86-undo-redo` | ✓ |\r\n| ~~Batch / staged editing mode~~ | **shipped** - demo `88-staged-editing` | ✓ |\r\n| ~~Per-column `valueParser`~~ | **shipped** - `valueParser` on `ColumnDef`; demo `175` | ✓ |\r\n| ~~Programmatic `api.startEditing()` / `stopEditing()`~~ | **shipped** - demo `176` | ✓ |\r\n| ~~Full-row editing mode~~ | **shipped** - `fullRowEditing`; demo `177` | ✓ |\r\n| ~~Async / server-loaded editor option lists~~ | **shipped** - `editorOptions` may return a Promise (per column or per row), with a loading state, caching and `api.refreshEditorOptions()`; demo `428-async-editor-options` | ✓ |\r\n\r\n## The real remaining gaps (short list)\r\n\r\nThe previous round shipped declarative col/row spanning, cell flash,\r\n`valueParser`, programmatic start/stop editing, full-row editing, multi-condition\r\nfilters, per-operator floating filters, and managed grid-to-grid row dragging -\r\nall with demos and docs. What is left is a short list of AG-Grid-Enterprise\r\nparity items, mostly UX affordances on top of engines that already exist:\r\n\r\nAudited against the code and the 171-demo catalog (four-way inventory, June 2026).\r\nThis list is deliberately short - most AG-Grid-Enterprise parity items already\r\nship (row-group panel `89`, status bar `144`, tool panel `146`, pivot + designer,\r\nserver-side row model `148`, export with images/styles `56`/`58`, charts,\r\nsparklines, collaboration). The genuine remaining gaps:\r\n\r\n| Gap | What exists today | Effort |\r\n| --- | ----------------- | ------ |\r\n| ~~**Multiple range selection** (Ctrl-drag additional cell ranges)~~ | **shipped** - Ctrl/Cmd+drag adds ranges; all highlight + copy together; `api.selectCells([...])` takes many; demo `118` | ✓ |\r\n| ~~**Cell data-type inference** (`cellDataType`)~~ | **shipped** - `cellDataType` on `ColumnDef` + grid-level `inferColumnTypes` | ✓ |\r\n| ~~**Merged-cell export to xlsx**~~ | **shipped** - `merges` option on `exportData` (single-sheet), lines up with `MergeSpec` | ✓ |\r\n| ~~**Filters tool panel tab**~~ | **shipped** - Columns \\| Filters tabs in the tool panel (`146`), in sync with the column menu | ✓ |\r\n| ~~**Copy with headers**~~ | **shipped** - `copyHeadersToClipboard` + `processCellForClipboard` hook | ✓ |\r\n| ~~**Aligned grids**~~ | **shipped** - `alignedGridGroup` syncs horizontal scroll + column-resize widths; demo `182` | ✓ |\r\n| ~~**Collapsible column groups**~~ | **shipped** - `columnGroupShow: 'open' \\| 'closed'` + `openByDefault`; demo `183` | ✓ |\r\n| ~~**Column menu tabs** (General / Filter / Columns)~~ | **shipped** - tabbed column menu; demo any filterable grid | ✓ |\r\n| ~~**External row-drag drop zones**~~ | **shipped** - `rowDropZone` action (drop rows onto any element); demo `184` | ✓ |\r\n| ~~**Nested master/detail grids**~~ | **shipped** - `isDetailRow` + `renderDetailRow` hosting a child grid; demo `181` | ✓ |\r\n\r\n### Still open (medium / large)\r\n\r\n| Gap | Note | Effort |\r\n| --- | ---- | ------ |\r\n| ~~**Multi Filter** (two conditions on one column)~~ | **shipped** - a column filter takes a second condition joined by AND / OR, in the menu and via `api.setFilter` | ✓ |\r\n| **Custom filter / floating-filter component** slot | first-class pluggable filter | M |\r\n| **Custom tool panels** | panel is fixed Columns + Filters | M |\r\n| ~~**UI-string localisation** (`localeText`)~~ | **shipped** - `localeText` prop over `GridMessages`; every menu/panel/chrome string is overridable | ✓ |\r\n| ~~**Row-grouping display modes** + group-level footers~~ | **shipped** - `groupDisplayMode: 'groupRows' \\| 'singleColumn' \\| 'multipleColumns'` plus `groupFooters` and `grandTotalRow`; demo `427-group-footers` | ✓ |\r\n| ~~**In-grid pivot mode** (toggle on the main grid)~~ | **shipped** - `enablePivot()` registers the engine and the main grid pivots in place | ✓ |\r\n| **Integrated-chart depth** (chart toolbar, cross-filtering) | 17 chart types + wizard + a \"Chart selected range\" context-menu item ship; the chart toolbar and click-to-filter loop do not | L |\r\n| **Server-side pivot / viewport row model** | SSRM ships sort/filter/group/infinite | L |\r\n\r\n## What's already there\r\n\r\nThe stable, built-in feature surface is large. Highlights: sorting (single +\r\nmulti), per-column filtering (menu + row + global) with a `between` range\r\noperator and set/tree/async filters, pagination, grouping + aggregation, tree\r\ndata, master/detail + full-width detail rows, row + column virtualization\r\n(100k+ and a 1M-row demo), cell-range selection + copy/paste + Excel-style fill\r\nhandle, inline editing with 14 editor types plus a custom `cellEditor` slot,\r\nundo/redo, staged editing, find-in-grid, notes + cell comments, tooltips,\r\nconditional formatting, sparklines, cell merging, column pinning/reorder/resize,\r\nrow pinning, a formula engine (+ HyperFormula adapter), server-side row model,\r\nExcel/PDF/CSV/HTML export + print (Enterprise), pivot + charts + AI (Enterprise),\r\nWAI-ARIA + keyboard nav, RTL, i18n, theming via `--sg-*` tokens, SSR, and a\r\nCSP-clean runtime.\r\n\r\n## How to contribute\r\n\r\n1. Pick a gap from **The real remaining gaps** above.\r\n2. Open an issue describing the API you'd want - names, types, the minimal change.\r\n3. If you can write the patch, do so, and keep tests with the change.\r\n"
3611
+ "markdown": "# Missing features\r\n\r\nAn honest accounting of what is **not yet built**, audited against the shipped\r\ndemo catalog. Most of what used to live here has shipped; the remaining gaps\r\nare small and clearly marked. Each entry has a rough effort estimate (S / M / L).\r\n\r\nShipped items are struck through with the demo or API that covers them, so you\r\ncan see both the trajectory and the (short) list of real gaps.\r\n\r\n## Columns\r\n\r\n| Gap | Status | Effort |\r\n| --- | ------ | ------ |\r\n| ~~`getRowId` prop~~ | **shipped** | ✓ |\r\n| ~~`cellClass(ctx)` / `rowClass(ctx)` callbacks~~ | **shipped** | ✓ |\r\n| ~~`getColumnWidths()` / `setColumnWidth()`~~ | **shipped** | ✓ |\r\n| ~~`setColumnPinning()` / `getColumnPinning()`~~ | **shipped** | ✓ |\r\n| ~~Header drag-to-reorder~~ | **shipped** - `enableColumnReorder`; demo `109-column-reorder-engine` | ✓ |\r\n| ~~Per-column disable sort / filter~~ | **shipped** - `sortable` / `filterable` on `ColumnDef` | ✓ |\r\n| ~~Column spanning~~ | **shipped** - cell merging via `MergeSpec` + `spreadsheetLayout` (demo `170`), **plus** declarative value-driven `colSpan` / `rowSpan` via `spansToMerges` | ✓ |\r\n\r\n## Rows\r\n\r\n| Gap | Status | Effort |\r\n| --- | ------ | ------ |\r\n| ~~Row pinning (top / bottom)~~ | **shipped** - `pinnedTopRows` / `pinnedBottomRows`; demos `107-pinned-rows`, `108-pinned-rows-engine` | ✓ |\r\n| ~~Row spanning (merged cells across rows)~~ | **shipped as cell merging** - `rowspan` in `MergeSpec`; demo `170-cell-merging` | ✓ |\r\n| ~~Full-width / detail row API~~ | **shipped** - `isDetailRow`; demo `106-detail-rows` | ✓ |\r\n| ~~Variable row height with `<SvGrid>`~~ | **shipped** - `rowHeight` accepts `(rowIndex) => px` | ✓ |\r\n| ~~Auto row height (measure content)~~ | **shipped** - `autoRowHeight` wraps cell text and measures each row, virtualization included | ✓ |\r\n| ~~`api.getDisplayedRows()`~~ | **shipped** | ✓ |\r\n| ~~Client-side tree data (hierarchical rows)~~ | **shipped** - `treeData` nests by parent id, `flattenTreeData` converts nested children; treegrid role + arrow-key expand; demo `426-tree-data` | ✓ |\r\n| ~~Built-in row dragging~~ | **shipped** - `rowDragManaged` reorders in-grid and moves rows **grid-to-grid** via a shared `rowDragGroup`; `onRowDragEnd` on the receiver; demos `105-row-reorder` (custom) + `180-row-dragging` (managed) | ✓ |\r\n\r\n## Cells\r\n\r\n| Gap | Status | Effort |\r\n| --- | ------ | ------ |\r\n| ~~Built-in tooltip API on `ColumnDef`~~ | **shipped** - `tooltip`; demo `85-tooltips-and-notes` | ✓ |\r\n| ~~Formula language / formula editor~~ | **shipped** - in-grid engine (demo `83-spreadsheet-formulas`), HyperFormula adapter (demo `173-hyperformula`), xlsx formulas (`101`, `119`) | ✓ |\r\n| ~~Find-in-grid~~ | **shipped** - Ctrl+F; demo `87-find-in-grid` | ✓ |\r\n| ~~Notes~~ | **shipped** - `notes` prop + cell comments; demos `85-tooltips-and-notes`, `91-cell-comments` | ✓ |\r\n| ~~Built-in cell flash / animated change highlight~~ | **shipped** - `cellFlash` on `ColumnDef` | ✓ |\r\n\r\n## Export / Print\r\n\r\n| Gap | Status | Effort |\r\n| --- | ------ | ------ |\r\n| ~~Excel / xlsx, PDF, CSV / TSV / HTML export, Print~~ | **shipped** in `@svgrid/enterprise` - demos `21`, `56`-`59`, `93`, `101`, `119`, `126`, `127` | ✓ |\r\n\r\n## Filtering\r\n\r\n| Gap | Status | Effort |\r\n| --- | ------ | ------ |\r\n| ~~`between` operator in the column menu~~ | **shipped** - demo `64-filter-between-operator` | ✓ |\r\n| ~~Set filter (tree-list, async, Excel-mode)~~ | **shipped** - demo `111-set-filter-advanced` | ✓ |\r\n| ~~Locale-aware text filtering~~ | **shipped** - demo `110-locale-aware-filter` | ✓ |\r\n| ~~`clearAllFilters()` / `getFilters()`~~ | **shipped** | ✓ |\r\n| ~~Floating filters (per-operator)~~ | **shipped** - filter row honours every operator per column with typed inputs + inline `between`; demo `179` | ✓ |\r\n| ~~Multi-condition filter within one column (AND / OR)~~ | **shipped** - two conditions per column via the funnel or `api.setFilter`; demo `178` | ✓ |\r\n\r\n## Editing\r\n\r\n| Gap | Status | Effort |\r\n| --- | ------ | ------ |\r\n| ~~`cellEditor` slot for custom inline editors~~ | **shipped** - demos `84-editor-types`, `66-custom-cell-editors` | ✓ |\r\n| ~~Built-in select & rich-select editors~~ | **shipped** - `editorType: 'list' / 'rich-select'`; demo `84-editor-types` | ✓ |\r\n| ~~Built-in large-text (textarea) editor~~ | **shipped** - demo `84-editor-types` | ✓ |\r\n| ~~Per-column `validate()`~~ | **shipped** - demos `24-validation`, `103-async-validation` | ✓ |\r\n| ~~Built-in undo / redo stack~~ | **shipped** - `api.undo()` / `redo()`; demo `86-undo-redo` | ✓ |\r\n| ~~Batch / staged editing mode~~ | **shipped** - demo `88-staged-editing` | ✓ |\r\n| ~~Per-column `valueParser`~~ | **shipped** - `valueParser` on `ColumnDef`; demo `175` | ✓ |\r\n| ~~Programmatic `api.startEditing()` / `stopEditing()`~~ | **shipped** - demo `176` | ✓ |\r\n| ~~Full-row editing mode~~ | **shipped** - `fullRowEditing`; demo `177` | ✓ |\r\n| ~~Async / server-loaded editor option lists~~ | **shipped** - `editorOptions` may return a Promise (per column or per row), with a loading state, caching and `api.refreshEditorOptions()`; demo `428-async-editor-options` | ✓ |\r\n\r\n## The real remaining gaps (short list)\r\n\r\nThe previous round shipped declarative col/row spanning, cell flash,\r\n`valueParser`, programmatic start/stop editing, full-row editing, multi-condition\r\nfilters, per-operator floating filters, and managed grid-to-grid row dragging -\r\nall with demos and docs. What is left is a short list of AG-Grid-Enterprise\r\nparity items, mostly UX affordances on top of engines that already exist:\r\n\r\nAudited against the code and the 171-demo catalog (four-way inventory, June 2026).\r\nThis list is deliberately short - most AG-Grid-Enterprise parity items already\r\nship (row-group panel `89`, status bar `144`, tool panel `146`, pivot + designer,\r\nserver-side row model `148`, export with images/styles `56`/`58`, charts,\r\nsparklines, collaboration). The genuine remaining gaps:\r\n\r\n| Gap | What exists today | Effort |\r\n| --- | ----------------- | ------ |\r\n| ~~**Multiple range selection** (Ctrl-drag additional cell ranges)~~ | **shipped** - Ctrl/Cmd+drag adds ranges; all highlight + copy together; `api.selectCells([...])` takes many; demo `118` | ✓ |\r\n| ~~**Cell data-type inference** (`cellDataType`)~~ | **shipped** - `cellDataType` on `ColumnDef` + grid-level `inferColumnTypes` | ✓ |\r\n| ~~**Merged-cell export to xlsx**~~ | **shipped** - `merges` option on `exportData` (single-sheet), lines up with `MergeSpec` | ✓ |\r\n| ~~**Filters tool panel tab**~~ | **shipped** - Columns \\| Filters tabs in the tool panel (`146`), in sync with the column menu | ✓ |\r\n| ~~**Copy with headers**~~ | **shipped** - `copyHeadersToClipboard` + `processCellForClipboard` hook | ✓ |\r\n| ~~**Aligned grids**~~ | **shipped** - `alignedGridGroup` syncs horizontal scroll + column-resize widths; demo `182` | ✓ |\r\n| ~~**Collapsible column groups**~~ | **shipped** - `columnGroupShow: 'open' \\| 'closed'` + `openByDefault`; demo `183` | ✓ |\r\n| ~~**Column menu tabs** (General / Filter / Columns)~~ | **shipped** - tabbed column menu; demo any filterable grid | ✓ |\r\n| ~~**External row-drag drop zones**~~ | **shipped** - `rowDropZone` action (drop rows onto any element); demo `184` | ✓ |\r\n| ~~**Nested master/detail grids**~~ | **shipped** - `isDetailRow` + `renderDetailRow` hosting a child grid; demo `181` | ✓ |\r\n\r\n### Still open (medium / large)\r\n\r\n| Gap | Note | Effort |\r\n| --- | ---- | ------ |\r\n| ~~**Multi Filter** (two conditions on one column)~~ | **shipped** - a column filter takes a second condition joined by AND / OR, in the menu and via `api.setFilter` | ✓ |\r\n| **Custom filter / floating-filter component** slot | first-class pluggable filter. Needs a MODEL seam as well as a render one: menu filters compile through `compileExcelFilter` from the closed `FilterOperator` union, so there is nowhere for a consumer predicate to enter today | L |\r\n| **Custom tool panels** | panel is fixed Columns + Filters (`toolPanelDefaultTab` is a `\"columns\" \\| \"filters\"` union, with no registry) | M |\r\n| ~~**UI-string localisation** (`localeText`)~~ | **shipped** - `localeText` prop over `GridMessages`; every menu/panel/chrome string is overridable | ✓ |\r\n| ~~**Row-grouping display modes** + group-level footers~~ | **shipped** - `groupDisplayMode: 'groupRows' \\| 'singleColumn' \\| 'multipleColumns'` plus `groupFooters` and `grandTotalRow`; demo `427-group-footers` | ✓ |\r\n| ~~**In-grid pivot mode** (toggle on the main grid)~~ | **shipped** - `enablePivot()` registers the engine and the main grid pivots in place | ✓ |\r\n| ~~**Integrated-chart depth** (chart toolbar, cross-filtering)~~ | **shipped** - `crossFilter` config plus `applyChartCrossFilter` / `clearChartCrossFilter`, wired from chart selection in `SvGridChartPanel`, with a Clear filter button; the panel toolbar has chart-type switching, export, AI, add-chart, tabs, maximize, dock and pop out | ✓ |\r\n| **Server-side pivot / viewport row model** | SSRM ships sort/filter/group/infinite | L |\r\n\r\n## What's already there\r\n\r\nThe stable, built-in feature surface is large. Highlights: sorting (single +\r\nmulti), per-column filtering (menu + row + global) with a `between` range\r\noperator and set/tree/async filters, pagination, grouping + aggregation, tree\r\ndata, master/detail + full-width detail rows, row + column virtualization\r\n(100k+ and a 1M-row demo), cell-range selection + copy/paste + Excel-style fill\r\nhandle, inline editing with 14 editor types plus a custom `cellEditor` slot,\r\nundo/redo, staged editing, find-in-grid, notes + cell comments, tooltips,\r\nconditional formatting, sparklines, cell merging, column pinning/reorder/resize,\r\nrow pinning, a formula engine (+ HyperFormula adapter), server-side row model,\r\nExcel/PDF/CSV/HTML export + print (Enterprise), pivot + charts + AI (Enterprise),\r\nWAI-ARIA + keyboard nav, RTL, i18n, theming via `--sg-*` tokens, SSR, and a\r\nCSP-clean runtime.\r\n\r\n## How to contribute\r\n\r\n1. Pick a gap from **The real remaining gaps** above.\r\n2. Open an issue describing the API you'd want - names, types, the minimal change.\r\n3. If you can write the patch, do so, and keep tests with the change.\r\n"
3594
3612
  },
3595
3613
  {
3596
3614
  "slug": "help/mobile-card-view",
3597
3615
  "path": "docs/help/mobile-card-view.md",
3598
3616
  "title": "Mobile card view",
3599
- "markdown": "# Mobile card view\n\nOn wide screens the grid is the grid. Under a viewport breakpoint\n(by convention 720 px), the same `$state` array re-renders as\ntouch-friendly cards. Tap a card to expand it into an edit panel; the\nwrite flows through `api.setCellValue` so dirty tracking, filtering,\nand external observers see every edit identically, whether it came\nfrom the desktop grid or from the mobile card.\n\n![A wide multi-column table on desktop collapsing to one stacked card per row of label and value pairs on a narrow screen.](/docs-media/grid-mobile-card.svg)\n\nThe headless engine is the single source of truth - filter, sort,\nand selection state live on `api`, so swapping between viewport sizes\npreserves the user's working set.\n\n<div data-docs-demo=\"81-mobile-card-view\" data-height=\"640\"></div>\n\n## The quick win: the `responsive` prop\n\nThe full card pivot below is the right call when the mobile layout is\ngenuinely different from the table. But most grids just need to *stay a\ntable* and stop being unusable on a phone - pinned columns eating the\nviewport, `fitColumns` crushing every column to nothing, no touch\nscroll. For that, flip one opt-in prop:\n\n```svelte\n<SvGrid data={rows} columns={columns} responsive={true} />\n```\n\nWhen the container measures narrower than the breakpoint (default\n640 px), `responsive` does three things, and undoes them the moment the\ncontainer grows back:\n\n1. **Suspends column pinning.** Your `columnPinning` state is left\n untouched - reads are just routed through an empty pinning set while\n narrow, so a phone shows a normally scrollable table instead of two\n frozen columns and a 20 px sliver. Pins snap back on rotate/resize.\n2. **Suspends `fitColumns`.** Squeezing 8 columns into 320 px makes every\n one unreadable. Narrow mode falls back to natural column widths with\n horizontal scroll.\n3. **Marks the scroll container** with `.sv-grid-narrow` and enables\n momentum touch scrolling, so you can target mobile tweaks in your own\n CSS.\n\nSet a custom breakpoint with the object form:\n\n```svelte\n<SvGrid responsive={{ breakpoint: 768 }} ... />\n```\n\n### Drop low-priority columns with `hideBelow`\n\nPair `responsive` with a per-column `hideBelow` (in px) to shed\nsecondary columns as the viewport narrows, keeping the columns that\nmatter:\n\n```ts\nconst columns: ColumnDef<F, Row>[] = [\n { field: 'symbol', header: 'Symbol', width: 90 }, // always shown\n { field: 'last', header: 'Last', width: 90 }, // always shown\n { field: 'sector', header: 'Sector', width: 140, hideBelow: 700 },\n { field: 'volume', header: 'Volume', width: 115, hideBelow: 700 },\n]\n```\n\nA column with `hideBelow: 700` is dropped whenever the measured\ncontainer width is under 700 px, and reappears above it. `hideBelow`\nonly takes effect when the grid has `responsive` set; on a desktop-only\ngrid it is inert. Because the column is removed from layout (not merely\nhidden), its width is reclaimed by the remaining columns.\n\nThis keeps the grid a real, sortable, editable grid on mobile - reach\nfor the card pivot below only when you want a fundamentally different\ntouch layout.\n\n## The pivot\n\nThree pieces wire the responsive pivot together:\n\n1. **A viewport observer** that flips a `$state` boolean under the\n breakpoint.\n2. **A single `data` array** shared between the grid and the card list.\n3. **A single mutation function** that writes through `api.setCellValue`\n when the grid is mounted, or mutates the array directly when only\n the card list is mounted.\n\n```ts\nconst MOBILE_MAX = 720\nlet isMobile = $state(false)\n\n$effect(() => {\n isMobile = window.innerWidth <= MOBILE_MAX\n const onResize = () => (isMobile = window.innerWidth <= MOBILE_MAX)\n window.addEventListener('resize', onResize)\n return () => window.removeEventListener('resize', onResize)\n})\n```\n\n## Complete drop-in example\n\nA ticket board that renders as a SvGrid on desktop and as a card list\non mobile. Both views write through the same `setCell` helper so the\ndata layer doesn't care which one is active.\n\n```svelte\n<script lang=\"ts\">\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n type ColumnDef,\n type SvGridApi,\n } from '@svgrid/grid'\n\n type Status = 'open' | 'in_progress' | 'blocked' | 'done'\n type Priority = 'low' | 'med' | 'high' | 'urgent'\n type Ticket = {\n id: string\n title: string\n assignee: string\n status: Status\n priority: Priority\n dueDate: string\n estimateHours: number\n }\n\n let rows = $state<Ticket[]>([\n { id: 't01', title: 'Onboarding wizard', assignee: 'Ada Lovelace', status: 'in_progress', priority: 'high', dueDate: '2026-06-15', estimateHours: 12 },\n { id: 't02', title: 'Stripe webhook retry', assignee: 'Linus Torvalds', status: 'open', priority: 'urgent', dueDate: '2026-06-10', estimateHours: 6 },\n { id: 't03', title: 'Search index migration',assignee: 'Grace Hopper', status: 'blocked', priority: 'med', dueDate: '2026-06-22', estimateHours: 16 },\n ])\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n let api = $state<SvGridApi<typeof features, Ticket> | null>(null)\n\n // ---- Viewport ----------------------------------------------------------\n const MOBILE_MAX = 720\n let isMobile = $state(false)\n $effect(() => {\n isMobile = window.innerWidth <= MOBILE_MAX\n const onResize = () => (isMobile = window.innerWidth <= MOBILE_MAX)\n window.addEventListener('resize', onResize)\n return () => window.removeEventListener('resize', onResize)\n })\n\n // ---- The one mutation path ---------------------------------------------\n function setCell<K extends keyof Ticket>(rowId: string, field: K, value: Ticket[K]) {\n const ix = rows.findIndex((r) => r.id === rowId)\n if (ix === -1) return\n if (api) api.setCellValue(ix, field as string, value)\n else (rows[ix] as Ticket)[field] = value\n }\n\n // ---- Card expand state -------------------------------------------------\n let expandedId = $state<string | null>(null)\n\n const STATUS_OPTS: Status[] = ['open', 'in_progress', 'blocked', 'done']\n const PRIO_OPTS: Priority[] = ['low', 'med', 'high', 'urgent']\n\n const columns: ColumnDef<typeof features, Ticket>[] = [\n { field: 'id', header: 'ID', editorType: 'text', width: 80, editable: false },\n { field: 'title', header: 'Title', editorType: 'text', width: 220 },\n { field: 'assignee', header: 'Assignee', editorType: 'text', width: 180 },\n { field: 'status', header: 'Status',\n editorType: 'list', editorOptions: STATUS_OPTS as unknown as ReadonlyArray<string>, width: 140 },\n { field: 'priority', header: 'Priority',\n editorType: 'list', editorOptions: PRIO_OPTS as unknown as ReadonlyArray<string>, width: 120 },\n { field: 'dueDate', header: 'Due', editorType: 'date', width: 130 },\n { field: 'estimateHours', header: 'Est. h', editorType: 'number', width: 100 },\n ]\n</script>\n\n<div style=\"height: 100%;\">\n {#if isMobile}\n <!-- ─────────── CARD LIST (mobile) ─────────── -->\n <div style=\"height: 100%; overflow-y: auto; display: flex; flex-direction: column; gap: 8px;\">\n {#each rows as r (r.id)}\n {@const open = expandedId === r.id}\n <article style=\"border: 1px solid #e2e8f0; border-radius: 12px; background: #fff;\">\n <button\n type=\"button\"\n onclick={() => (expandedId = open ? null : r.id)}\n style=\"width: 100%; text-align: left; border: 0; background: transparent; padding: 12px 14px; cursor: pointer;\"\n >\n <div style=\"display: flex; gap: 8px; align-items: center; margin-bottom: 6px;\">\n <code style=\"font-size: 11px; color: #64748b;\">{r.id}</code>\n <span style=\"font-size: 10px; font-weight: 700; text-transform: uppercase; padding: 2px 8px; border-radius: 999px; background: #fee2e2; color: #991b1b;\">\n {r.priority}\n </span>\n </div>\n <div style=\"font-weight: 600; font-size: 15px; color: #0f172a;\">{r.title}</div>\n <div style=\"display: flex; gap: 12px; margin-top: 6px; font-size: 12px; color: #64748b;\">\n <span>👤 {r.assignee}</span>\n <span>📅 {r.dueDate}</span>\n <span>⏱ {r.estimateHours}h</span>\n </div>\n </button>\n\n {#if open}\n <div style=\"border-top: 1px solid #e2e8f0; padding: 14px; background: #f8fafc; display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px;\">\n <label style=\"grid-column: 1 / -1;\">\n Title\n <input type=\"text\" value={r.title}\n oninput={(e) => setCell(r.id, 'title', (e.currentTarget as HTMLInputElement).value)} />\n </label>\n <label>\n Status\n <select value={r.status}\n onchange={(e) => setCell(r.id, 'status', (e.currentTarget as HTMLSelectElement).value as Status)}>\n {#each STATUS_OPTS as s (s)}<option value={s}>{s}</option>{/each}\n </select>\n </label>\n <label>\n Priority\n <select value={r.priority}\n onchange={(e) => setCell(r.id, 'priority', (e.currentTarget as HTMLSelectElement).value as Priority)}>\n {#each PRIO_OPTS as p (p)}<option value={p}>{p}</option>{/each}\n </select>\n </label>\n </div>\n {/if}\n </article>\n {/each}\n </div>\n {:else}\n <!-- ─────────── GRID (desktop) ─────────── -->\n <SvGrid\n data={rows}\n columns={columns}\n features={features}\n enableInlineEditing={true}\n enableCellSelection={true}\n rowHeight={40}\n containerHeight=\"100%\"\n fitColumns={true}\n onApiReady={(next) => (api = next)}\n />\n {/if}\n</div>\n```\n\n## Why the two-path mutation matters\n\nThe card view mounts only when the grid is *un*mounted. When the user\nedits a field in card mode, `api` is `null` because the grid component\nis not in the DOM. The `setCell` helper handles both cases:\n\n```ts\nfunction setCell<K extends keyof Ticket>(rowId: string, field: K, value: Ticket[K]) {\n const ix = rows.findIndex((r) => r.id === rowId)\n if (ix === -1) return\n if (api) api.setCellValue(ix, field as string, value)\n else (rows[ix] as Ticket)[field] = value\n}\n```\n\nWhy bother with both? Two reasons:\n\n- When the grid is mounted, `api.setCellValue` emits `onCellValueChange`,\n triggers validators, and updates dirty tracking. Direct mutation\n skips all of that.\n- When the grid is unmounted, `api` is `null`. Direct mutation is the\n only path that still updates the underlying `$state` array.\n\nIf you want **identical observer behavior in both modes**, register\nyour `onCellValueChange` callbacks against the `$state` array via a\n`$effect` instead of via the grid prop. Then either path triggers it.\n\n## A force-toggle for testing\n\nUsers on tablets sit awkwardly across the breakpoint; QA wants to\nverify both views without resizing. Add a manual override:\n\n```svelte\n<script lang=\"ts\">\n let forceView = $state<'auto' | 'grid' | 'cards'>('auto')\n const showCards = $derived(forceView === 'cards' || (forceView === 'auto' && isMobile))\n</script>\n\n<div role=\"group\" aria-label=\"View mode\">\n <button onclick={() => (forceView = 'auto')} class:on={forceView === 'auto'}>Auto</button>\n <button onclick={() => (forceView = 'grid')} class:on={forceView === 'grid'}>Grid</button>\n <button onclick={() => (forceView = 'cards')} class:on={forceView === 'cards'}>Cards</button>\n</div>\n```\n\nThen drive the conditional render off `showCards` instead of `isMobile`.\n\n## Density parity\n\nWhen the user toggles between views, density should not jolt. Match\n`rowHeight` (grid) with the card height (cards):\n\n```svelte\n<SvGrid rowHeight={96} ... />\n\n<style>\n .card-head { height: 96px; }\n</style>\n```\n\nA 96 px row is large for a desktop grid but matches a typical mobile\ncard. If your grid is denser, pick a card height closer to your row\nheight (e.g. 56 px for compact grids), and put the title + meta in\none line.\n\n## See also\n\n- [Demo 81 - Mobile card view](../../examples/src/demos/81-mobile-card-view.svelte) - full source with KPI strip, view-mode toggle, and per-priority colour bars\n- [Kanban board mode](./rows/kanban-board.md) - the grid's built-in `board` prop renders rows as cards in lanes with drag-and-drop\n- [Conditional form schema](./conditional-form-schema.md) - if your card form needs declarative field-visibility rules\n\n## Frequently asked questions\n\n### Is SvGrid responsive / mobile-friendly?\n\nYes, at two levels. The lightweight path is the built-in `responsive` prop: set\n`responsive={true}` and, under the breakpoint (640 px by default), the grid\nun-pins columns, suspends `fitColumns`, enables touch scrolling, and drops any\ncolumns marked `hideBelow` - so it stays a real table without eating the\nviewport. The heavier path is the card pivot: above a breakpoint (720 px by\nconvention) it renders as a normal grid; below it, the same `$state` data\nre-renders as touch-friendly cards. Both are driven by one headless engine, so\nedits and state stay in sync.\n\n### What is the difference between `responsive` and `hideBelow`?\n\n`responsive` is a grid-level prop that turns on all the narrow-container\nbehavior (un-pinning, `fitColumns` suspension, touch scroll, the\n`.sv-grid-narrow` class). `hideBelow` is a per-column number (px) that drops\nthat one column when the container is narrower than the value. `hideBelow` only\ndoes anything when the grid also has `responsive` set.\n\n### How do edits on mobile cards stay consistent with the grid?\n\nCard edits flow through `api.setCellValue`, the same path desktop grid edits use.\nDirty tracking, filtering, and external observers see every change identically\nregardless of which view produced it.\n\n### Can I reuse the same data for a Kanban or card layout?\n\nYes. The headless engine can drive multiple views from one data source - the\nmobile card view and the Kanban demo are the same pattern with different\nrendering.\n"
3617
+ "markdown": "# Mobile card view\n\nOn wide screens the grid is the grid. Under a viewport breakpoint\n(by convention 720 px), the same `$state` array re-renders as\ntouch-friendly cards. Tap a card to expand it into an edit panel; the\nwrite flows through `api.setCellValue` so dirty tracking, filtering,\nand external observers see every edit identically, whether it came\nfrom the desktop grid or from the mobile card.\n\n![A wide multi-column table on desktop collapsing to one stacked card per row of label and value pairs on a narrow screen.](/docs-media/grid-mobile-card.svg)\n\nThe headless engine is the single source of truth - filter, sort,\nand selection state live on `api`, so swapping between viewport sizes\npreserves the user's working set.\n\n<div data-docs-demo=\"81-mobile-card-view\" data-height=\"640\"></div>\n\n## The quick win: the `responsive` prop\n\nThe full card pivot below is the right call when the mobile layout is\ngenuinely different from the table. But most grids just need to *stay a\ntable* and stop being unusable on a phone - pinned columns eating the\nviewport, `fitColumns` crushing every column to nothing, no touch\nscroll. For that, flip one opt-in prop:\n\n```svelte\n<SvGrid data={rows} columns={columns} responsive={true} />\n```\n\nWhen the container measures narrower than the breakpoint (default\n640 px), `responsive` does three things, and undoes them the moment the\ncontainer grows back:\n\n1. **Suspends column pinning.** Your `columnPinning` state is left\n untouched - reads are just routed through an empty pinning set while\n narrow, so a phone shows a normally scrollable table instead of two\n frozen columns and a 20 px sliver. Pins snap back on rotate/resize.\n2. **Suspends `fitColumns`.** Squeezing 8 columns into 320 px makes every\n one unreadable. Narrow mode falls back to natural column widths with\n horizontal scroll.\n3. **Marks the scroll container** with `.sv-grid-narrow` and enables\n momentum touch scrolling, so you can target mobile tweaks in your own\n CSS.\n\nSet a custom breakpoint with the object form:\n\n```svelte\n<SvGrid responsive={{ breakpoint: 768 }} ... />\n```\n\n### Drop low-priority columns with `hideBelow`\n\nPair `responsive` with a per-column `hideBelow` (in px) to shed\nsecondary columns as the viewport narrows, keeping the columns that\nmatter:\n\n```ts\nconst columns: ColumnDef<F, Row>[] = [\n { field: 'symbol', header: 'Symbol', width: 90 }, // always shown\n { field: 'last', header: 'Last', width: 90 }, // always shown\n { field: 'sector', header: 'Sector', width: 140, hideBelow: 700 },\n { field: 'volume', header: 'Volume', width: 115, hideBelow: 700 },\n]\n```\n\nA column with `hideBelow: 700` is dropped whenever the measured\ncontainer width is under 700 px, and reappears above it. `hideBelow`\nonly takes effect when the grid has `responsive` set; on a desktop-only\ngrid it is inert. Because the column is removed from layout (not merely\nhidden), its width is reclaimed by the remaining columns.\n\nThis keeps the grid a real, sortable, editable grid on mobile - reach\nfor the card pivot below only when you want a fundamentally different\ntouch layout.\n\n## The pivot\n\nThree pieces wire the responsive pivot together:\n\n1. **A viewport observer** that flips a `$state` boolean under the\n breakpoint.\n2. **A single `data` array** shared between the grid and the card list.\n3. **A single mutation function** that writes through `api.setCellValue`\n when the grid is mounted, or mutates the array directly when only\n the card list is mounted.\n\n```ts\nconst MOBILE_MAX = 720\nlet isMobile = $state(false)\n\n$effect(() => {\n isMobile = window.innerWidth <= MOBILE_MAX\n const onResize = () => (isMobile = window.innerWidth <= MOBILE_MAX)\n window.addEventListener('resize', onResize)\n return () => window.removeEventListener('resize', onResize)\n})\n```\n\n## Complete drop-in example\n\nA ticket board that renders as a SvGrid on desktop and as a card list\non mobile. Both views write through the same `setCell` helper so the\ndata layer doesn't care which one is active.\n\n```svelte\n<script lang=\"ts\">\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n type ColumnDef,\n type SvGridApi,\n } from '@svgrid/grid'\n\n type Status = 'open' | 'in_progress' | 'blocked' | 'done'\n type Priority = 'low' | 'med' | 'high' | 'urgent'\n type Ticket = {\n id: string\n title: string\n assignee: string\n status: Status\n priority: Priority\n dueDate: string\n estimateHours: number\n }\n\n let rows = $state<Ticket[]>([\n { id: 't01', title: 'Onboarding wizard', assignee: 'Ada Lovelace', status: 'in_progress', priority: 'high', dueDate: '2026-06-15', estimateHours: 12 },\n { id: 't02', title: 'Stripe webhook retry', assignee: 'Linus Torvalds', status: 'open', priority: 'urgent', dueDate: '2026-06-10', estimateHours: 6 },\n { id: 't03', title: 'Search index migration',assignee: 'Grace Hopper', status: 'blocked', priority: 'med', dueDate: '2026-06-22', estimateHours: 16 },\n ])\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n let api = $state<SvGridApi<typeof features, Ticket> | null>(null)\n\n // ---- Viewport ----------------------------------------------------------\n const MOBILE_MAX = 720\n let isMobile = $state(false)\n $effect(() => {\n isMobile = window.innerWidth <= MOBILE_MAX\n const onResize = () => (isMobile = window.innerWidth <= MOBILE_MAX)\n window.addEventListener('resize', onResize)\n return () => window.removeEventListener('resize', onResize)\n })\n\n // ---- The one mutation path ---------------------------------------------\n function setCell<K extends keyof Ticket>(rowId: string, field: K, value: Ticket[K]) {\n const ix = rows.findIndex((r) => r.id === rowId)\n if (ix === -1) return\n if (api) api.setCellValue(ix, field as string, value)\n else (rows[ix] as Ticket)[field] = value\n }\n\n // ---- Card expand state -------------------------------------------------\n let expandedId = $state<string | null>(null)\n\n const STATUS_OPTS: Status[] = ['open', 'in_progress', 'blocked', 'done']\n const PRIO_OPTS: Priority[] = ['low', 'med', 'high', 'urgent']\n\n const columns: ColumnDef<typeof features, Ticket>[] = [\n { field: 'id', header: 'ID', editorType: 'text', width: 80, editable: false },\n { field: 'title', header: 'Title', editorType: 'text', width: 220 },\n { field: 'assignee', header: 'Assignee', editorType: 'text', width: 180 },\n { field: 'status', header: 'Status',\n editorType: 'list', editorOptions: STATUS_OPTS as unknown as ReadonlyArray<string>, width: 140 },\n { field: 'priority', header: 'Priority',\n editorType: 'list', editorOptions: PRIO_OPTS as unknown as ReadonlyArray<string>, width: 120 },\n { field: 'dueDate', header: 'Due', editorType: 'date', width: 130 },\n { field: 'estimateHours', header: 'Est. h', editorType: 'number', width: 100 },\n ]\n</script>\n\n<div style=\"height: 100%;\">\n {#if isMobile}\n <!-- ─────────── CARD LIST (mobile) ─────────── -->\n <div style=\"height: 100%; overflow-y: auto; display: flex; flex-direction: column; gap: 8px;\">\n {#each rows as r (r.id)}\n {@const open = expandedId === r.id}\n <article style=\"border: 1px solid #e2e8f0; border-radius: 12px; background: #fff;\">\n <button\n type=\"button\"\n onclick={() => (expandedId = open ? null : r.id)}\n style=\"width: 100%; text-align: left; border: 0; background: transparent; padding: 12px 14px; cursor: pointer;\"\n >\n <div style=\"display: flex; gap: 8px; align-items: center; margin-bottom: 6px;\">\n <code style=\"font-size: 11px; color: #64748b;\">{r.id}</code>\n <span style=\"font-size: 10px; font-weight: 700; text-transform: uppercase; padding: 2px 8px; border-radius: 999px; background: #fee2e2; color: #991b1b;\">\n {r.priority}\n </span>\n </div>\n <div style=\"font-weight: 600; font-size: 15px; color: #0f172a;\">{r.title}</div>\n <div style=\"display: flex; gap: 12px; margin-top: 6px; font-size: 12px; color: #64748b;\">\n <span>👤 {r.assignee}</span>\n <span>📅 {r.dueDate}</span>\n <span>⏱ {r.estimateHours}h</span>\n </div>\n </button>\n\n {#if open}\n <div style=\"border-top: 1px solid #e2e8f0; padding: 14px; background: #f8fafc; display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px;\">\n <label style=\"grid-column: 1 / -1;\">\n Title\n <input type=\"text\" value={r.title}\n oninput={(e) => setCell(r.id, 'title', (e.currentTarget as HTMLInputElement).value)} />\n </label>\n <label>\n Status\n <select value={r.status}\n onchange={(e) => setCell(r.id, 'status', (e.currentTarget as HTMLSelectElement).value as Status)}>\n {#each STATUS_OPTS as s (s)}<option value={s}>{s}</option>{/each}\n </select>\n </label>\n <label>\n Priority\n <select value={r.priority}\n onchange={(e) => setCell(r.id, 'priority', (e.currentTarget as HTMLSelectElement).value as Priority)}>\n {#each PRIO_OPTS as p (p)}<option value={p}>{p}</option>{/each}\n </select>\n </label>\n </div>\n {/if}\n </article>\n {/each}\n </div>\n {:else}\n <!-- ─────────── GRID (desktop) ─────────── -->\n <SvGrid\n data={rows}\n columns={columns}\n features={features}\n enableInlineEditing={true}\n enableCellSelection={true}\n rowHeight={40}\n containerHeight=\"100%\"\n fitColumns={true}\n onApiReady={(next) => (api = next)}\n />\n {/if}\n</div>\n```\n\n## Why the two-path mutation matters\n\nThe card view mounts only when the grid is *un*mounted. When the user\nedits a field in card mode, `api` is `null` because the grid component\nis not in the DOM. The `setCell` helper handles both cases:\n\n```ts\nfunction setCell<K extends keyof Ticket>(rowId: string, field: K, value: Ticket[K]) {\n const ix = rows.findIndex((r) => r.id === rowId)\n if (ix === -1) return\n if (api) api.setCellValue(ix, field as string, value)\n else (rows[ix] as Ticket)[field] = value\n}\n```\n\nWhy bother with both? Two reasons:\n\n- When the grid is mounted, `api.setCellValue` writes through the grid's\n own copy of the data, so the change is reflected in `api.getData()`,\n the rendered cells, and anything derived from them. Direct mutation of\n your `$state` array does not reach the mounted grid's copy.\n- When the grid is unmounted, `api` is `null`. Direct mutation is the\n only path that still updates the underlying `$state` array.\n\nNote that `api.setCellValue` is a programmatic write, not an edit\ncommit: it does **not** fire the `onCellValueChange` prop. That callback\nbelongs to the inline editor's commit path, so neither branch of\n`setCell` above triggers it.\n\nThat makes the two modes consistent, but it means the grid prop is the\nwrong place to hang your side effects. For **identical observer\nbehavior in both modes**, react to the `$state` array with a `$effect`\ninstead of using the `onCellValueChange` prop - both branches write to\ndata the effect can see.\n\n## A force-toggle for testing\n\nUsers on tablets sit awkwardly across the breakpoint; QA wants to\nverify both views without resizing. Add a manual override:\n\n```svelte\n<script lang=\"ts\">\n let forceView = $state<'auto' | 'grid' | 'cards'>('auto')\n const showCards = $derived(forceView === 'cards' || (forceView === 'auto' && isMobile))\n</script>\n\n<div role=\"group\" aria-label=\"View mode\">\n <button onclick={() => (forceView = 'auto')} class:on={forceView === 'auto'}>Auto</button>\n <button onclick={() => (forceView = 'grid')} class:on={forceView === 'grid'}>Grid</button>\n <button onclick={() => (forceView = 'cards')} class:on={forceView === 'cards'}>Cards</button>\n</div>\n```\n\nThen drive the conditional render off `showCards` instead of `isMobile`.\n\n## Density parity\n\nWhen the user toggles between views, density should not jolt. Match\n`rowHeight` (grid) with the card height (cards):\n\n```svelte\n<SvGrid rowHeight={96} ... />\n\n<style>\n .card-head { height: 96px; }\n</style>\n```\n\nA 96 px row is large for a desktop grid but matches a typical mobile\ncard. If your grid is denser, pick a card height closer to your row\nheight (e.g. 56 px for compact grids), and put the title + meta in\none line.\n\n## See also\n\n- [Demo 81 - Mobile card view](../../examples/src/demos/81-mobile-card-view.svelte) - full source with KPI strip, view-mode toggle, and per-priority colour bars\n- [Kanban board mode](./rows/kanban-board.md) - the grid's built-in `board` prop renders rows as cards in lanes with drag-and-drop\n- [Conditional form schema](./conditional-form-schema.md) - if your card form needs declarative field-visibility rules\n\n## Frequently asked questions\n\n### Is SvGrid responsive / mobile-friendly?\n\nYes, at two levels. The lightweight path is the built-in `responsive` prop: set\n`responsive={true}` and, under the breakpoint (640 px by default), the grid\nun-pins columns, suspends `fitColumns`, enables touch scrolling, and drops any\ncolumns marked `hideBelow` - so it stays a real table without eating the\nviewport. The heavier path is the card pivot: above a breakpoint (720 px by\nconvention) it renders as a normal grid; below it, the same `$state` data\nre-renders as touch-friendly cards. Both are driven by one headless engine, so\nedits and state stay in sync.\n\n### What is the difference between `responsive` and `hideBelow`?\n\n`responsive` is a grid-level prop that turns on all the narrow-container\nbehavior (un-pinning, `fitColumns` suspension, touch scroll, the\n`.sv-grid-narrow` class). `hideBelow` is a per-column number (px) that drops\nthat one column when the container is narrower than the value. `hideBelow` only\ndoes anything when the grid also has `responsive` set.\n\n### How do edits on mobile cards stay consistent with the grid?\n\nCard edits flow through `api.setCellValue`, the same path desktop grid edits use.\nDirty tracking, filtering, and external observers see every change identically\nregardless of which view produced it.\n\n### Can I reuse the same data for a Kanban or card layout?\n\nYes. The headless engine can drive multiple views from one data source - the\nmobile card view and the Kanban demo are the same pattern with different\nrendering.\n"
3600
3618
  },
3601
3619
  {
3602
3620
  "slug": "help/observability",
@@ -3614,19 +3632,19 @@ export const docs = [
3614
3632
  "slug": "help/production",
3615
3633
  "path": "docs/help/production.md",
3616
3634
  "title": "Production deployment",
3617
- "markdown": "# Production deployment\r\n\r\nThe checklist that turns \"it works on my laptop\" into \"it ships\". One\r\npage per concern; each concern is one paragraph + the code that\r\nmatters.\r\n\r\n![Six production concerns turn a working grid into a shippable one: server-side data, virtualization, accessibility, SSR and hydration, CSP, and TypeScript.](/docs-media/grid-production.svg)\r\n\r\n<div data-docs-demo=\"22-admin-template\" data-height=\"540\"></div>\r\n\r\n## 1. Pin your versions\r\n\r\n```json\r\n{\r\n \"dependencies\": {\r\n \"@svgrid/grid\": \"1.0.0\",\r\n \"@svgrid/enterprise\": \"1.0.0\"\r\n }\r\n}\r\n```\r\n\r\nPre-1.0, prefer exact pins (no `^`, no `~`). The\r\n[changelog](../changelog.md) annotates breaking changes; the\r\n[API stability page](./api-stability.md) names which exports are\r\nunder the semver promise.\r\n\r\n## 2. Bundle size: what actually ships\r\n\r\nMeasured gzipped, with Svelte excluded as a peer dependency:\r\n\r\n| What you import | Gzipped | Minified |\r\n| ----------------------------------------------------- | ------- | -------- |\r\n| Headless core (`createGrid` + `createCoreRowModel`) | ~2 KB | ~7 KB |\r\n| Full `<SvGrid>` render component (everything) | ~78 KB | ~340 KB |\r\n\r\nAdd ~9 KB gzipped for the render component's CSS. Charts, date/time\r\neditors, menus, and export split into `import()` chunks (~64 KB total)\r\nthat load on demand. Re-measure with `pnpm size`.\r\n\r\nThe `<SvGrid>` component is batteries-included: virtualization, Excel-style\r\nfilters, inline editing, grouping, tree, master/detail, and accessibility are\r\nall in that one import. For a smaller footprint, use the headless core and\r\nrender your own markup, registering only the features you need.\r\n\r\nEnterprise adds per feature you import:\r\n\r\n| Enterprise module | Approx KB | Peer deps |\r\n| ------------------- | --------- | ------------------------------- |\r\n| `exportGrid` (csv/tsv/html) | ~6 KB | - |\r\n| + xlsx | ~6 KB | `jszip` (loaded on first xlsx call) |\r\n| + pdf | ~9 KB | `pdfmake` (loaded on first pdf call) |\r\n| `importData` | ~7 KB | `jszip` (xlsx only) |\r\n| AI helpers | ~3 KB | -. You bring your provider. |\r\n| `createPivotModel` | ~4 KB | - |\r\n\r\nUse the **subpath imports** to avoid pulling features you don't use:\r\n\r\n```ts\r\nimport { exportGrid } from '@svgrid/enterprise/export' // export only\r\nimport { createPivotModel } from '@svgrid/enterprise/pivot' // pivot only\r\n```\r\n\r\n## 3. Peer dependencies\r\n\r\n| Peer dep | When you need it | Install |\r\n| ---------- | ------------------------------------------------------------------ | ----------------------------------- |\r\n| `svelte` | Always. SvGrid renders against Svelte 5. | `pnpm add svelte` |\r\n| `jszip` | xlsx export OR xlsx import. | `pnpm add jszip` |\r\n| `pdfmake` | PDF export. | `pnpm add pdfmake` |\r\n\r\nBoth `jszip` and `pdfmake` are dynamic imports - the bundle splits and\r\nloads them on the first call. Nothing ships in your initial chunk until\r\nthe user actually clicks \"Export to xlsx\".\r\n\r\n## 4. Lazy-load Enterprise at route boundaries\r\n\r\nIf only one route in your app needs export, gate `installEnterprise` behind a\r\ndynamic import so the rest of the app doesn't ship the Enterprise bundle:\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import type { SvGridApi } from '@svgrid/grid'\r\n import type { EnterpriseGridApi } from '@svgrid/enterprise'\r\n\r\n let api = $state<SvGridApi<typeof features, Order> | null>(null)\r\n let pro = $state<EnterpriseGridApi<typeof features, Order> | null>(null)\r\n\r\n async function enablePro() {\r\n if (!api) return\r\n const { installEnterprise, setLicenseKey } = await import('@svgrid/enterprise')\r\n setLicenseKey(import.meta.env.VITE_SVPRO_KEY)\r\n pro = installEnterprise(api)\r\n }\r\n</script>\r\n\r\n<SvGrid {...} onApiReady={(next) => (api = next)} />\r\n\r\n<button onclick={enablePro}>Enable export</button>\r\n{#if pro}\r\n <button onclick={() => pro?.exportData({ format: 'xlsx' })}>⬇ XLSX</button>\r\n{/if}\r\n```\r\n\r\n## 5. License the Enterprise pack\r\n\r\n```ts\r\n// main.ts (or +layout.svelte for SvelteKit)\r\nimport { setLicenseKey } from '@svgrid/enterprise'\r\n\r\nif (import.meta.env.VITE_SVPRO_KEY) {\r\n setLicenseKey(import.meta.env.VITE_SVPRO_KEY)\r\n}\r\n```\r\n\r\nEnterprise is **soft-gated** - it works unlicensed, but renders a small\r\nwatermark + a one-time console nudge. Set the key once at app\r\nstartup; both disappear.\r\n\r\nDon't commit the key to source control. Inject via env (Vite reads\r\n`VITE_*` variables at build time; SvelteKit reads `$env/static/public`).\r\n\r\nFor per-tenant deployments where each tenant has their own key, set\r\nthe key inside the consumer's bootstrap, never inside the library\r\npackage.\r\n\r\n## 6. CSP-safe deployment\r\n\r\nThe recommended `Content-Security-Policy` header:\r\n\r\n```\r\nContent-Security-Policy:\r\n default-src 'self';\r\n script-src 'self';\r\n style-src 'self' 'unsafe-inline';\r\n img-src 'self' data:;\r\n font-src 'self' data:;\r\n connect-src 'self';\r\n frame-ancestors 'none';\r\n base-uri 'self';\r\n form-action 'self';\r\n```\r\n\r\nNo `'unsafe-eval'`, no `'unsafe-inline'` on `script-src`. SvGrid\r\nCommunity + Enterprise run clean under this policy. [Demo 16](../../examples/src/demos/16-csp-compliant.svelte)\r\nincludes a runtime self-check.\r\n\r\nIf you ship in an iframe (embedded analytics, dashboards), add\r\n`frame-ancestors` to the host's CSP to allow the embed.\r\n\r\n## 7. SSR\r\n\r\nFor SvelteKit:\r\n\r\n```ts\r\n// +page.server.ts\r\nexport async function load() {\r\n const rows = await db.query('select * from people limit 100')\r\n return { rows }\r\n}\r\n```\r\n\r\n```svelte\r\n<!-- +page.svelte -->\r\n<script lang=\"ts\">\r\n import { SvGrid, tableFeatures, rowSortingFeature } from '@svgrid/grid'\r\n let { data } = $props()\r\n const features = tableFeatures({ rowSortingFeature })\r\n</script>\r\n\r\n<SvGrid data={data.rows} columns={columns} features={features} />\r\n```\r\n\r\nThe first paint contains the data in a real `<table>` (good for SEO +\r\nLCP). Hydration only attaches event listeners. See\r\n[demo 19](../../examples/src/demos/19-ssr.svelte) for a sandboxed\r\nJS-disabled iframe that proves the markup is meaningful pre-hydration.\r\n\r\n## 8. Performance budgets\r\n\r\nTargets that have held up in production:\r\n\r\n| Surface | Target | What you do if you miss it |\r\n| ---------------------------------- | -------------------- | ---------------------------------------------------------------- |\r\n| Time to first row visible | < 200 ms | Lazy-load Enterprise. Defer non-critical columns. Smaller initial page. |\r\n| Scroll FPS (10k rows, virtualized) | 60 FPS | Cap `overscan`. Avoid `cell` render functions that allocate per render. |\r\n| Sort over 100k rows | < 60 ms | Set `editorType` on numeric / date columns so `sortFns.number` / `sortFns.date` get used instead of `sortFns.auto`. |\r\n| Filter input → re-render | < 30 ms | Debounce server-side filters; the local filter UI is already ≤ 16 ms for 10k rows. |\r\n| Export 10k rows to xlsx | < 1 s | Don't include columns you're going to hide. Use `columns: [...]` to project. |\r\n\r\nThe [benchmarks page](./benchmarks.md) has the reproducible numbers.\r\n\r\n## 9. Error boundaries\r\n\r\nThe render component throws on truly broken state (e.g. a `field` that\r\ndoesn't exist on any row). Wrap in a Svelte error boundary or guard\r\nwith `if (rows.length === 0)` for empty data. The grid's `emptyMessage`\r\nprop covers the empty case without crashing.\r\n\r\n```svelte\r\n<svelte:boundary>\r\n <SvGrid {data} {columns} {features} />\r\n\r\n {#snippet failed(error, reset)}\r\n <div class=\"error\">Grid failed: {error.message}</div>\r\n <button onclick={reset}>Retry</button>\r\n {/snippet}\r\n</svelte:boundary>\r\n```\r\n\r\n## 10. Monitoring + observability\r\n\r\nThe grid emits everything you'd want to observe via callbacks:\r\n`onSortingChange`, `onFiltersChange`, `onRowSelectionChange`,\r\n`onCellValueChange`. Wire them into your analytics / logging:\r\n\r\n```ts\r\nfunction track(event: string, payload: object) {\r\n // sentry, posthog, your own beacon - pick one\r\n}\r\n\r\n<SvGrid\r\n ...\r\n onSortingChange={(s) => track('grid.sort', { clauses: s })}\r\n onFiltersChange={(f) => track('grid.filter', { columns: f.columns.length })}\r\n onCellValueChange={(e) => track('grid.edit', { column: e.columnId })}\r\n/>\r\n```\r\n\r\n## See also\r\n\r\n- [Why headless?](../why-headless.md) - the architectural decision\r\n behind Community + Enterprise.\r\n- [API stability](./api-stability.md) - the semver promise and what\r\n it covers.\r\n- [Security](./security.md) - peer-dep table, SBOM, vulnerability\r\n handling, data residency.\r\n- [Browser support](./browser-support.md) - tested matrix, mobile,\r\n build tools.\r\n"
3635
+ "markdown": "# Production deployment\r\n\r\nThe checklist that turns \"it works on my laptop\" into \"it ships\". One\r\npage per concern; each concern is one paragraph + the code that\r\nmatters.\r\n\r\n![Six production concerns turn a working grid into a shippable one: server-side data, virtualization, accessibility, SSR and hydration, CSP, and TypeScript.](/docs-media/grid-production.svg)\r\n\r\n<div data-docs-demo=\"22-admin-template\" data-height=\"540\"></div>\r\n\r\n## 1. Pin your versions\r\n\r\n```json\r\n{\r\n \"dependencies\": {\r\n \"@svgrid/grid\": \"1.0.0\",\r\n \"@svgrid/enterprise\": \"1.0.0\"\r\n }\r\n}\r\n```\r\n\r\nPre-1.0, prefer exact pins (no `^`, no `~`). The\r\n[changelog](../changelog.md) annotates breaking changes; the\r\n[API stability page](./api-stability.md) names which exports are\r\nunder the semver promise.\r\n\r\n## 2. Bundle size: what actually ships\r\n\r\nMeasured gzipped, with Svelte excluded as a peer dependency:\r\n\r\n| What you import | Gzipped | Minified |\r\n| ----------------------------------------------------- | ------- | -------- |\r\n| Headless core (`createGrid` + `createCoreRowModel`) | ~2 KB | ~7 KB |\r\n| Full `<SvGrid>` render component (everything) | ~77 KB | ~340 KB |\r\n\r\nAdd ~9 KB gzipped for the render component's CSS. Charts, date/time\r\neditors, menus, and export split into `import()` chunks (~64 KB total)\r\nthat load on demand. Re-measure with `pnpm size`.\r\n\r\nThe `<SvGrid>` component is batteries-included: virtualization, Excel-style\r\nfilters, inline editing, grouping, tree, master/detail, and accessibility are\r\nall in that one import. For a smaller footprint, use the headless core and\r\nrender your own markup, registering only the features you need.\r\n\r\nEnterprise adds per feature you import:\r\n\r\n| Enterprise module | Approx KB | Peer deps |\r\n| ------------------- | --------- | ------------------------------- |\r\n| `exportGrid` (csv/tsv/html) | ~6 KB | - |\r\n| + xlsx | ~6 KB | `jszip` (loaded on first xlsx call) |\r\n| + pdf | ~9 KB | `pdfmake` (loaded on first pdf call) |\r\n| `importData` | ~7 KB | `jszip` (xlsx only) |\r\n| AI helpers | ~3 KB | -. You bring your provider. |\r\n| `createPivotModel` | ~4 KB | - |\r\n\r\nUse the **subpath imports** to avoid pulling features you don't use:\r\n\r\n```ts\r\nimport { exportGrid } from '@svgrid/enterprise/export' // export only\r\nimport { createPivotModel } from '@svgrid/enterprise/pivot' // pivot only\r\n```\r\n\r\n## 3. Peer dependencies\r\n\r\n| Peer dep | When you need it | Install |\r\n| ---------- | ------------------------------------------------------------------ | ----------------------------------- |\r\n| `svelte` | Always. SvGrid renders against Svelte 5. | `pnpm add svelte` |\r\n| `jszip` | xlsx export OR xlsx import. | `pnpm add jszip` |\r\n| `pdfmake` | PDF export. | `pnpm add pdfmake` |\r\n\r\nBoth `jszip` and `pdfmake` are dynamic imports - the bundle splits and\r\nloads them on the first call. Nothing ships in your initial chunk until\r\nthe user actually clicks \"Export to xlsx\".\r\n\r\n## 4. Lazy-load Enterprise at route boundaries\r\n\r\nIf only one route in your app needs export, gate `installEnterprise` behind a\r\ndynamic import so the rest of the app doesn't ship the Enterprise bundle:\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import type { SvGridApi } from '@svgrid/grid'\r\n import type { EnterpriseGridApi } from '@svgrid/enterprise'\r\n\r\n let api = $state<SvGridApi<typeof features, Order> | null>(null)\r\n let pro = $state<EnterpriseGridApi<typeof features, Order> | null>(null)\r\n\r\n async function enablePro() {\r\n if (!api) return\r\n const { installEnterprise, setLicenseKey } = await import('@svgrid/enterprise')\r\n setLicenseKey(import.meta.env.VITE_SVPRO_KEY)\r\n pro = installEnterprise(api)\r\n }\r\n</script>\r\n\r\n<SvGrid {...} onApiReady={(next) => (api = next)} />\r\n\r\n<button onclick={enablePro}>Enable export</button>\r\n{#if pro}\r\n <button onclick={() => pro?.exportData({ format: 'xlsx' })}>⬇ XLSX</button>\r\n{/if}\r\n```\r\n\r\n## 5. License the Enterprise pack\r\n\r\n```ts\r\n// main.ts (or +layout.svelte for SvelteKit)\r\nimport { setLicenseKey } from '@svgrid/enterprise'\r\n\r\nif (import.meta.env.VITE_SVPRO_KEY) {\r\n setLicenseKey(import.meta.env.VITE_SVPRO_KEY)\r\n}\r\n```\r\n\r\nEnterprise is **soft-gated** - it works unlicensed, but renders a small\r\nwatermark + a one-time console nudge. Set the key once at app\r\nstartup; both disappear.\r\n\r\nDon't commit the key to source control. Inject via env (Vite reads\r\n`VITE_*` variables at build time; SvelteKit reads `$env/static/public`).\r\n\r\nFor per-tenant deployments where each tenant has their own key, set\r\nthe key inside the consumer's bootstrap, never inside the library\r\npackage.\r\n\r\n## 6. CSP-safe deployment\r\n\r\nThe recommended `Content-Security-Policy` header:\r\n\r\n```\r\nContent-Security-Policy:\r\n default-src 'self';\r\n script-src 'self';\r\n style-src 'self' 'unsafe-inline';\r\n img-src 'self' data:;\r\n font-src 'self' data:;\r\n connect-src 'self';\r\n frame-ancestors 'none';\r\n base-uri 'self';\r\n form-action 'self';\r\n```\r\n\r\nNo `'unsafe-eval'`, no `'unsafe-inline'` on `script-src`. SvGrid\r\nCommunity + Enterprise run clean under this policy. [Demo 16](../../examples/src/demos/16-csp-compliant.svelte)\r\nincludes a runtime self-check.\r\n\r\nIf you ship in an iframe (embedded analytics, dashboards), add\r\n`frame-ancestors` to the host's CSP to allow the embed.\r\n\r\n## 7. SSR\r\n\r\nFor SvelteKit:\r\n\r\n```ts\r\n// +page.server.ts\r\nexport async function load() {\r\n const rows = await db.query('select * from people limit 100')\r\n return { rows }\r\n}\r\n```\r\n\r\n```svelte\r\n<!-- +page.svelte -->\r\n<script lang=\"ts\">\r\n import { SvGrid, tableFeatures, rowSortingFeature } from '@svgrid/grid'\r\n let { data } = $props()\r\n const features = tableFeatures({ rowSortingFeature })\r\n</script>\r\n\r\n<SvGrid data={data.rows} columns={columns} features={features} />\r\n```\r\n\r\nThe first paint contains the data in a real `<table>` (good for SEO +\r\nLCP). Hydration only attaches event listeners. See\r\n[demo 19](../../examples/src/demos/19-ssr.svelte) for a sandboxed\r\nJS-disabled iframe that proves the markup is meaningful pre-hydration.\r\n\r\n## 8. Performance budgets\r\n\r\nTargets that have held up in production:\r\n\r\n| Surface | Target | What you do if you miss it |\r\n| ---------------------------------- | -------------------- | ---------------------------------------------------------------- |\r\n| Time to first row visible | < 200 ms | Lazy-load Enterprise. Defer non-critical columns. Smaller initial page. |\r\n| Scroll FPS (10k rows, virtualized) | 60 FPS | Cap `overscan`. Avoid `cell` render functions that allocate per render. |\r\n| Sort over 100k rows | < 60 ms | Set `editorType` on numeric / date columns so `sortFns.number` / `sortFns.date` get used instead of `sortFns.auto`. |\r\n| Filter input → re-render | < 30 ms | Debounce server-side filters; the local filter UI is already ≤ 16 ms for 10k rows. |\r\n| Export 10k rows to xlsx | < 1 s | Don't include columns you're going to hide. Use `columns: [...]` to project. |\r\n\r\nThe [benchmarks page](./benchmarks.md) has the reproducible numbers.\r\n\r\n## 9. Error boundaries\r\n\r\nThe render component throws on truly broken state (e.g. a `field` that\r\ndoesn't exist on any row). Wrap in a Svelte error boundary or guard\r\nwith `if (rows.length === 0)` for empty data. The grid's `emptyMessage`\r\nprop covers the empty case without crashing.\r\n\r\n```svelte\r\n<svelte:boundary>\r\n <SvGrid {data} {columns} {features} />\r\n\r\n {#snippet failed(error, reset)}\r\n <div class=\"error\">Grid failed: {error.message}</div>\r\n <button onclick={reset}>Retry</button>\r\n {/snippet}\r\n</svelte:boundary>\r\n```\r\n\r\n## 10. Monitoring + observability\r\n\r\nThe grid emits everything you'd want to observe via callbacks:\r\n`onSortingChange`, `onFiltersChange`, `onRowSelectionChange`,\r\n`onCellValueChange`. Wire them into your analytics / logging:\r\n\r\n```ts\r\nfunction track(event: string, payload: object) {\r\n // sentry, posthog, your own beacon - pick one\r\n}\r\n\r\n<SvGrid\r\n ...\r\n onSortingChange={(s) => track('grid.sort', { clauses: s })}\r\n onFiltersChange={(f) => track('grid.filter', { columns: f.columns.length })}\r\n onCellValueChange={(e) => track('grid.edit', { column: e.columnId })}\r\n/>\r\n```\r\n\r\n## See also\r\n\r\n- [Why headless?](../why-headless.md) - the architectural decision\r\n behind Community + Enterprise.\r\n- [API stability](./api-stability.md) - the semver promise and what\r\n it covers.\r\n- [Security](./security.md) - peer-dep table, SBOM, vulnerability\r\n handling, data residency.\r\n- [Browser support](./browser-support.md) - tested matrix, mobile,\r\n build tools.\r\n"
3618
3636
  },
3619
3637
  {
3620
3638
  "slug": "help/real-time",
3621
3639
  "path": "docs/help/real-time.md",
3622
3640
  "title": "Real-time / streaming updates",
3623
- "markdown": "# Real-time / streaming updates\n\nHow to drive the grid from a WebSocket / SSE / poll. Three patterns\nranked by the rate of change:\n\n1. **Periodic full refresh** - poll for the latest rows, swap the array.\n2. **Cell flash on change** - the same swap, but the renderer\n highlights cells whose values just changed.\n3. **Delta merge with backlog** - WebSocket pushes individual row\n patches; you merge them into the in-memory state, optionally\n batching while the user has the page paused.\n\n![A live WebSocket or SSE feed pushes deltas that apply as add, update, or remove operations, run through the grid as a keyed row transaction, and surface as a cell flash on the changed row.](/docs-media/grid-realtime.svg)\n\nTry a streaming order desk - cell flashes on every change, pause / resume,\ndisconnect / reconnect, configurable throughput slider:\n\n<div data-docs-demo=\"34-realtime-orders\" data-height=\"520\"></div>\n\n## Pattern 1: periodic full refresh\n\nThe simplest reactive pattern. Poll every N seconds, hand the new\narray down.\n\n```svelte\n<script lang=\"ts\">\n let rows = $state<Order[]>([])\n\n $effect(() => {\n const id = setInterval(async () => {\n rows = await fetch('/api/orders').then((r) => r.json())\n }, 5_000)\n return () => clearInterval(id)\n })\n</script>\n\n<SvGrid data={rows} columns={columns} features={features} />\n```\n\nThe grid re-renders the visible rows; virtualization keeps the cost\nproportional to the viewport, not the dataset.\n\n**Use when:** dataset is small (< 1000 rows), update rate is low (≥ 5 s),\n\"freshness\" is the only requirement.\n\n**Avoid when:** the user is mid-edit on a cell. A full swap mid-edit\nwill close the editor. Pause your refresh while\n`onActiveCellChange` reports a non-null active cell.\n\n## Pattern 2: cell flash on change\n\nSame swap, but each cell snippet tracks its previous value and renders\na brief highlight when it differs.\n\n```svelte\n<script lang=\"ts\">\n // Track per-row, per-field last-seen values.\n let lastSeen = new Map<string, Record<string, unknown>>()\n function diff(rowId: string, current: Record<string, unknown>): Record<string, boolean> {\n const prev = lastSeen.get(rowId)\n const changed: Record<string, boolean> = {}\n if (prev) for (const k of Object.keys(current)) if (prev[k] !== current[k]) changed[k] = true\n lastSeen.set(rowId, { ...current })\n return changed\n }\n</script>\n\n{#snippet PriceCell(props: { row: Order })}\n {@const changes = diff(props.row.id, props.row)}\n <span class={`tabular-nums ${changes.price ? 'flash' : ''}`}>\n {fmtMoney(props.row.price)}\n </span>\n{/snippet}\n```\n\n```css\n.flash {\n animation: flash-bg 800ms ease-out;\n}\n@keyframes flash-bg {\n from { background: rgba(250, 204, 21, 0.4); }\n to { background: transparent; }\n}\n```\n\n**Use when:** user wants to spot changes. The stock-ticker pattern.\n\n**Avoid when:** the flash interferes with selection or accessibility -\nadd `prefers-reduced-motion` guards if the flash is purely\ndecorative.\n\n## Pattern 3: delta merge with backlog\n\nThe grown-up pattern for higher-rate updates. The server pushes\nindividual row patches; you maintain an in-memory map and reassign the\narray when needed.\n\n```svelte\n<script lang=\"ts\">\n type OrderId = string\n let rowsMap = $state(new Map<OrderId, Order>())\n let paused = $state(false)\n let backlog = $state<Order[]>([])\n const flushDebounce = 200 // ms\n\n const rows = $derived(Array.from(rowsMap.values()))\n\n $effect(() => {\n const ws = new WebSocket('/api/orders/stream')\n ws.onmessage = (e) => {\n const patch: Order = JSON.parse(e.data)\n if (paused) {\n backlog = [...backlog, patch]\n return\n }\n applyPatch(patch)\n }\n return () => ws.close()\n })\n\n function applyPatch(patch: Order) {\n const next = new Map(rowsMap)\n next.set(patch.id, patch)\n rowsMap = next\n }\n\n function resume() {\n paused = false\n for (const p of backlog) applyPatch(p)\n backlog = []\n }\n</script>\n\n<button onclick={() => (paused = !paused)}>\n {paused ? `Resume (${backlog.length} pending)` : 'Pause'}\n</button>\n\n<SvGrid data={rows} columns={columns} features={features} />\n```\n\nA few production knobs:\n\n- **Debounce the resignment.** A WebSocket spitting out 20 patches per\n second creates 20 array allocations per second. Batch into the next\n `requestAnimationFrame`:\n\n ```ts\n let pendingPatches: Order[] = []\n let scheduled = false\n function applyPatch(patch: Order) {\n pendingPatches.push(patch)\n if (scheduled) return\n scheduled = true\n requestAnimationFrame(() => {\n const next = new Map(rowsMap)\n for (const p of pendingPatches) next.set(p.id, p)\n rowsMap = next\n pendingPatches = []\n scheduled = false\n })\n }\n ```\n\n- **Out-of-order safety.** Each patch carries a server-side sequence\n number; drop patches older than the latest you've applied for that\n row. The streaming demo (#34) shows this with `lastSeq` per row.\n\n- **Disconnect handling.** On `ws.onclose`, set a status flag the user\n sees (\"reconnecting…\") and reconnect with exponential backoff. The\n demo shows the recovery flow.\n\n## Pause during edit / selection\n\nA common mistake: updates fly in while the user is mid-paste or\nmid-edit. The fix is two flags:\n\n```ts\nlet isEditing = $state(false)\nlet hasSelection = $state(false)\nconst isInteracting = $derived(isEditing || hasSelection)\n\n$effect(() => {\n if (isInteracting) {\n paused = true\n } else if (paused) {\n resume()\n }\n})\n```\n\nWire to the grid:\n\n```svelte\n<SvGrid\n ...\n onEditingChange={(state) => (isEditing = state.cell != null)}\n onRowSelectionChange={({ selectedRows }) => (hasSelection = selectedRows.length > 0)}\n/>\n```\n\n## Backpressure (when the server is too fast)\n\nIf your producer can outrun the UI's frame budget, sample down at the\nclient. Drop intermediate patches for the same row; only the newest\none survives:\n\n```ts\nfunction applyPatch(patch: Order) {\n // pendingPatches is keyed by row id so a fast-moving row only\n // commits its NEWEST value per frame.\n pendingPatches.set(patch.id, patch)\n /* ...schedule rAF... */\n}\n```\n\nFor 1000 rows updating at 5 Hz, this caps the work at 1000\nassignments per frame regardless of the actual message rate. The\nstreaming demo's \"throughput slider\" stresses this exact path.\n\n## Combining with sort + filter\n\nThe grid's sort + filter run AFTER your patches land in `rows`. Two\nimplications:\n\n- **A row that no longer matches the filter disappears.** Expected.\n- **The active row may move.** When `onActiveCellChange` fires with a\n new index because the row above shifted, your toolbar / detail panel\n should follow.\n\nIf you're using a side detail panel keyed on row id (not index),\nthis is a non-issue - the detail stays bound to the order even as\nthe row moves.\n\n## See also\n\n- [Server-side data](./server-side-data.md) - the pull side of\n remote data.\n- [Saved views](./saved-views.md) - persist a \"live mode on / off\"\n toggle alongside the rest of the view config.\n- [Performance benchmarks](./benchmarks.md) - measured per-frame cost\n of the patterns above.\n\n## Frequently asked questions\n\n### How do I show real-time data in SvGrid?\n\nDrive the grid's `data` from a WebSocket, SSE, or poll. This page covers three\npatterns ranked by update rate: periodic full refresh, targeted row patches via\n`getRowId`, and high-frequency cell updates with change-flash highlighting.\n\n### Will the grid keep my scroll and selection on live updates?\n\nYes, if you give rows a stable identity with `getRowId`. Then selection,\nexpansion, edit state, and scroll position survive incoming updates instead of\nresetting on every refresh.\n\n### How fast can SvGrid update?\n\nFast enough for tick-by-tick feeds - the stock-market demo updates 25 symbols\nevery 250 ms with green/red cell flashes while sorting and selection stay live.\nFor very high rates, patch only changed rows rather than swapping the whole\narray.\n"
3641
+ "markdown": "# Real-time / streaming updates\n\nHow to drive the grid from a WebSocket / SSE / poll. Three patterns\nranked by the rate of change:\n\n1. **Periodic full refresh** - poll for the latest rows, swap the array.\n2. **Cell flash on change** - the same swap, but the renderer\n highlights cells whose values just changed.\n3. **Delta merge with backlog** - WebSocket pushes individual row\n patches; you merge them into the in-memory state, optionally\n batching while the user has the page paused.\n\n![A live WebSocket or SSE feed pushes deltas that apply as add, update, or remove operations, run through the grid as a keyed row transaction, and surface as a cell flash on the changed row.](/docs-media/grid-realtime.svg)\n\nTry a streaming order desk - cell flashes on every change, pause / resume,\ndisconnect / reconnect, configurable throughput slider:\n\n<div data-docs-demo=\"34-realtime-orders\" data-height=\"520\"></div>\n\n## Pattern 1: periodic full refresh\n\nThe simplest reactive pattern. Poll every N seconds, hand the new\narray down.\n\n```svelte\n<script lang=\"ts\">\n let rows = $state<Order[]>([])\n\n $effect(() => {\n const id = setInterval(async () => {\n rows = await fetch('/api/orders').then((r) => r.json())\n }, 5_000)\n return () => clearInterval(id)\n })\n</script>\n\n<SvGrid data={rows} columns={columns} features={features} />\n```\n\nThe grid re-renders the visible rows; virtualization keeps the cost\nproportional to the viewport, not the dataset.\n\n**Use when:** dataset is small (< 1000 rows), update rate is low (≥ 5 s),\n\"freshness\" is the only requirement.\n\n**Avoid when:** the user is mid-edit on a cell. A full swap mid-edit\nwill close the editor. Pause your refresh while\n`onActiveCellChange` reports a non-null active cell.\n\n## Pattern 2: cell flash on change\n\nSame swap, but each cell snippet tracks its previous value and renders\na brief highlight when it differs.\n\n```svelte\n<script lang=\"ts\">\n // Track per-row, per-field last-seen values.\n let lastSeen = new Map<string, Record<string, unknown>>()\n function diff(rowId: string, current: Record<string, unknown>): Record<string, boolean> {\n const prev = lastSeen.get(rowId)\n const changed: Record<string, boolean> = {}\n if (prev) for (const k of Object.keys(current)) if (prev[k] !== current[k]) changed[k] = true\n lastSeen.set(rowId, { ...current })\n return changed\n }\n</script>\n\n{#snippet PriceCell(props: { row: Order })}\n {@const changes = diff(props.row.id, props.row)}\n <span class={`tabular-nums ${changes.price ? 'flash' : ''}`}>\n {fmtMoney(props.row.price)}\n </span>\n{/snippet}\n```\n\n```css\n.flash {\n animation: flash-bg 800ms ease-out;\n}\n@keyframes flash-bg {\n from { background: rgba(250, 204, 21, 0.4); }\n to { background: transparent; }\n}\n```\n\n**Use when:** user wants to spot changes. The stock-ticker pattern.\n\n**Avoid when:** the flash interferes with selection or accessibility -\nadd `prefers-reduced-motion` guards if the flash is purely\ndecorative.\n\n## Pattern 3: delta merge with backlog\n\nThe grown-up pattern for higher-rate updates. The server pushes\nindividual row patches; you maintain an in-memory map and reassign the\narray when needed.\n\n```svelte\n<script lang=\"ts\">\n type OrderId = string\n let rowsMap = $state(new Map<OrderId, Order>())\n let paused = $state(false)\n let backlog = $state<Order[]>([])\n const flushDebounce = 200 // ms\n\n const rows = $derived(Array.from(rowsMap.values()))\n\n $effect(() => {\n const ws = new WebSocket('/api/orders/stream')\n ws.onmessage = (e) => {\n const patch: Order = JSON.parse(e.data)\n if (paused) {\n backlog = [...backlog, patch]\n return\n }\n applyPatch(patch)\n }\n return () => ws.close()\n })\n\n function applyPatch(patch: Order) {\n const next = new Map(rowsMap)\n next.set(patch.id, patch)\n rowsMap = next\n }\n\n function resume() {\n paused = false\n for (const p of backlog) applyPatch(p)\n backlog = []\n }\n</script>\n\n<button onclick={() => (paused = !paused)}>\n {paused ? `Resume (${backlog.length} pending)` : 'Pause'}\n</button>\n\n<SvGrid data={rows} columns={columns} features={features} />\n```\n\nA few production knobs:\n\n- **Debounce the resignment.** A WebSocket spitting out 20 patches per\n second creates 20 array allocations per second. Batch into the next\n `requestAnimationFrame`:\n\n ```ts\n let pendingPatches: Order[] = []\n let scheduled = false\n function applyPatch(patch: Order) {\n pendingPatches.push(patch)\n if (scheduled) return\n scheduled = true\n requestAnimationFrame(() => {\n const next = new Map(rowsMap)\n for (const p of pendingPatches) next.set(p.id, p)\n rowsMap = next\n pendingPatches = []\n scheduled = false\n })\n }\n ```\n\n- **Out-of-order safety.** Each patch carries a server-side sequence\n number; drop patches older than the latest you've applied for that\n row. The streaming demo (#34) shows this with `lastSeq` per row.\n\n- **Disconnect handling.** On `ws.onclose`, set a status flag the user\n sees (\"reconnecting…\") and reconnect with exponential backoff. The\n demo shows the recovery flow.\n\n## Pause during edit / selection\n\nA common mistake: updates fly in while the user is mid-paste or\nmid-edit. The fix is two flags:\n\n```ts\nlet isEditing = $state(false)\nlet hasSelection = $state(false)\nconst isInteracting = $derived(isEditing || hasSelection)\n\n$effect(() => {\n if (isInteracting) {\n paused = true\n } else if (paused) {\n resume()\n }\n})\n```\n\nWire to the grid:\n\n```svelte\n<SvGrid\n ...\n onCellDoubleClick={() => (isEditing = true)}\n onCellValueChange={() => (isEditing = false)}\n onRowSelectionChange={(selection, rows) => (hasSelection = rows.length > 0)}\n/>\n```\n\n`onRowSelectionChange` receives two positional arguments - the\n`{ [rowId]: true }` record and the array of selected rows.\n\nThere is no dedicated editing-state callback today, so the edit flag is\nassembled from the two ends of the edit: a double-click opens the\neditor, and a committed value closes it. That leaves one gap - an edit\nabandoned with `Escape` commits nothing, so clear the flag on the\nwrapper's `onkeydown` too:\n\n```svelte\n<div onkeydown={(e) => { if (e.key === 'Escape') isEditing = false }}>\n <SvGrid ... />\n</div>\n```\n\nIf your app starts edits itself through `api.startEditing()` /\n`api.stopEditing()`, set the flag at those call sites instead - you\nalready know the state there, and it covers every exit path.\n\n## Backpressure (when the server is too fast)\n\nIf your producer can outrun the UI's frame budget, sample down at the\nclient. Drop intermediate patches for the same row; only the newest\none survives:\n\n```ts\nfunction applyPatch(patch: Order) {\n // pendingPatches is keyed by row id so a fast-moving row only\n // commits its NEWEST value per frame.\n pendingPatches.set(patch.id, patch)\n /* ...schedule rAF... */\n}\n```\n\nFor 1000 rows updating at 5 Hz, this caps the work at 1000\nassignments per frame regardless of the actual message rate. The\nstreaming demo's \"throughput slider\" stresses this exact path.\n\n## Combining with sort + filter\n\nThe grid's sort + filter run AFTER your patches land in `rows`. Two\nimplications:\n\n- **A row that no longer matches the filter disappears.** Expected.\n- **The active row may move.** When `onActiveCellChange` fires with a\n new index because the row above shifted, your toolbar / detail panel\n should follow.\n\nIf you're using a side detail panel keyed on row id (not index),\nthis is a non-issue - the detail stays bound to the order even as\nthe row moves.\n\n## See also\n\n- [Server-side data](./server-side-data.md) - the pull side of\n remote data.\n- [Saved views](./saved-views.md) - persist a \"live mode on / off\"\n toggle alongside the rest of the view config.\n- [Performance benchmarks](./benchmarks.md) - measured per-frame cost\n of the patterns above.\n\n## Frequently asked questions\n\n### How do I show real-time data in SvGrid?\n\nDrive the grid's `data` from a WebSocket, SSE, or poll. This page covers three\npatterns ranked by update rate: periodic full refresh, targeted row patches via\n`getRowId`, and high-frequency cell updates with change-flash highlighting.\n\n### Will the grid keep my scroll and selection on live updates?\n\nYes, if you give rows a stable identity with `getRowId`. Then selection,\nexpansion, edit state, and scroll position survive incoming updates instead of\nresetting on every refresh.\n\n### How fast can SvGrid update?\n\nFast enough for tick-by-tick feeds - the stock-market demo updates 25 symbols\nevery 250 ms with green/red cell flashes while sorting and selection stay live.\nFor very high rates, patch only changed rows rather than swapping the whole\narray.\n"
3624
3642
  },
3625
3643
  {
3626
3644
  "slug": "help/recipes",
3627
3645
  "path": "docs/help/recipes.md",
3628
3646
  "title": "Recipes / Cookbook",
3629
- "markdown": "# Recipes / Cookbook\n\nQuick patterns for the questions that come up over and over. Each\nrecipe is paired with a live demo or a minimal snippet that runs\nagainst the shipping library.\n\n## Sort, filter, paginate at the same time\n\nThree of the most-asked features wired together against a 5k-row\ndataset. Click any header to sort, open the filter icon to filter,\nuse the pager at the bottom to walk pages:\n\n<div data-docs-demo=\"02-sort-filter-paginate\" data-height=\"440\"></div>\n\nThe implementation: register all three features in one\n`tableFeatures(...)` call and enable the matching props on `<SvGrid>`.\nSee [Filtering overview](./filtering/overview.md) and\n[Row sorting](./rows/row-sorting.md).\n\n## Select rows + copy / paste a range\n\nClick + drag selects a rectangular cell range; Ctrl/Cmd+C copies as\nTSV; pasting back from Excel writes through the same cell-write\npipeline as inline edit:\n\n<div data-docs-demo=\"04-selection-copy-paste\" data-height=\"440\"></div>\n\nThe pattern: enable `enableCellSelection={true}` and let the grid\nhandle the keyboard + paste plumbing. Cell writes go through\n`onCellValueChange` so your validator runs on every pasted value.\n\n## Bulk actions toolbar\n\nThe Gmail / Linear pattern: tick row checkboxes, a sticky toolbar\nreveals \"Mark / Delete / Copy as TSV\":\n\n<div data-docs-demo=\"23-bulk-actions\" data-height=\"440\"></div>\n\nThe trick: subscribe to `onRowSelectionChange` and render your\ntoolbar whenever the count is > 0. The grid doesn't ship a bulk-action\ncomponent on purpose - your design system's button + toast UI is\nbetter than anything we could embed.\n\n## Cascade editing (formula-like dependencies)\n\nEditing qty / price cascades into line totals + a summary card:\n\n<div data-docs-demo=\"18-cascade-editing\" data-height=\"440\"></div>\n\nIn `onCellValueChange`, after writing the cell, recompute the dependent\ncells in the same row (or in the summary state) and let Svelte 5's\nreactivity carry the update through. The pattern is essentially\n\"editable cells + a `$derived` summary\".\n\n## Validation while editing\n\nPer-column rules: invalid commits get rolled back via `setCellValue`,\nand a sidebar logs the rejection so users can audit what was tried:\n\n<div data-docs-demo=\"24-validation\" data-height=\"440\"></div>\n\nUse `onCellValueChange`: validate, and if invalid, call\n`api.setCellValue(rowIndex, columnId, oldValue)` to undo. The grid\nemits a fresh `onCellValueChange` for the undo write that your\nvalidator can recognise and skip.\n\n## Column pinning + freezing\n\nWide 13-column grid: pin Company on the left and Price on the right;\nthe middle scrolls under sticky edges:\n\n<div data-docs-demo=\"25-column-pinning\" data-height=\"440\"></div>\n\nThe user pins via the column menu - no extra wiring needed if\n`columnFilteringFeature` is registered (the menu lives on the same\nheader button). Or call `api.pinColumn(columnId, 'left' | 'right' | null)`\nprogrammatically.\n\n## List + chips editors\n\nThe two built-in multi-select editors:\n\n<div data-docs-demo=\"26-list-chips-editors\" data-height=\"440\"></div>\n\nAdd `editorType: 'list'` for a single `<select>` or `editorType: 'chips'`\nwith `editorMultiple={true}` for removable tokens. Pass static\n`editorOptions` or a `(row) => options` callback for cascading lists\n(e.g. City options depend on Country).\n\n## Spreadsheet ribbon\n\nAn Excel-style Ribbon UI driving the grid via `SvGridApi`: bold + colour\n+ number format + insert/delete row + sort, plus a live SUM/AVG/COUNT\nstatus bar:\n\n<div data-docs-demo=\"27-spreadsheet-ribbon\" data-height=\"500\"></div>\n\nThe ribbon is a regular Svelte component; every button calls\n`api.setCellValue`, `api.addRow`, `api.setSort`, etc. Status bar\nreads `getDisplayedRows()` for the live aggregate.\n\n## Master / detail\n\nA hierarchical row that expands to reveal a child grid:\n\n<div data-docs-demo=\"08-tree-and-master-detail\" data-height=\"440\"></div>\n\nSame flatten pattern as [tree rows](./rows/tree-rows.md): a flat\n`visibleRows` derivation, plus a custom cell snippet that renders the\ndetail grid inline when the row is expanded.\n\n## Forms-in-grid (master grid + side form)\n\nClick a row to load it into a tabbed detail form on the right; edits\nflow back to the grid live:\n\n<div data-docs-demo=\"40-forms-master-detail\" data-height=\"500\"></div>\n\nPattern: `onActiveCellChange` -> set `activeRowId` -> `draft = $state.snapshot(activeRow)` -> form\nbinds against `draft`. On save, write `draft` back into the rows array.\n**Use `$state.snapshot()`, not `structuredClone()`**, on Svelte 5 state\nproxies - the latter throws `DataCloneError`.\n\n## Saved views / persistence\n\nPivot-lite with chips + saved views stored in localStorage:\n\n<div data-docs-demo=\"36-reporting-workspace\" data-height=\"500\"></div>\n\nThe pattern: a `view: { groupBy, sortBy, filters, columns }` object\nthat you serialise into localStorage under a versioned key\n(`'my-app:view:v1'`). On load, hydrate the state from the saved view\nand call the matching `api.set*` methods.\n\n## Theming studio\n\nLive token playground: brand color, density, radius, font, dark/light,\nzebra. The CSS snippet at the bottom is copy-ready:\n\n<div data-docs-demo=\"37-theming-studio\" data-height=\"540\"></div>\n\nTheming is `--sg-*` custom properties - see [Tailwind integration](./tailwind.md)\nfor the full token list.\n\n## Localisation + RTL switching\n\nSix locales (en, de, fr-CA, ja, ar, he); headers, currencies, dates,\nand the grid's own scrollbar flip:\n\n<div data-docs-demo=\"38-rtl-i18n\" data-height=\"500\"></div>\n\nSet `<html dir=\"...\">` and `<html lang=\"...\">` based on the locale.\nPer-column `format: { type: 'currency', locales }` overrides the\ndocument locale for specific columns.\n\n## Print + boardroom export\n\nQuarterly P&L print pack: cover page, repeat-on-page headers, page-\nsize + orientation, CSV / HTML download, browser-native PDF:\n\n<div data-docs-demo=\"39-print-board-export\" data-height=\"500\"></div>\n\n`api.print({ title, columns, rows, orientation, pageSize })` opens a\nsandbox window with an isolated HTML document and calls `.print()` on\nit. The popup pattern is what isolates your print stylesheet from your\napp's CSS.\n\n## Healthcare EMR (role-based editing)\n\nInpatient board with vitals sparklines, risk score, code status, allergy\nchips, role-based editing (viewer / nurse / physician / admin):\n\n<div data-docs-demo=\"41-healthcare-emr\" data-height=\"500\"></div>\n\nThe role gate uses `editable: (ctx) => roleCanEdit(currentRole, ctx.column.id)`.\nThat lambda runs per-cell, so you get column + role granularity\nwithout a separate config table.\n\n## Live shipment / fleet board\n\nStreaming-style live update at 2-3 Hz with cell-level flash on change,\nETA delta colors, alert chips:\n\n<div data-docs-demo=\"42-logistics-fleet\" data-height=\"500\"></div>\n\nThe pattern: a `$state` rows array that you mutate from a\nWebSocket/SSE handler. The render component diffs at the data-array\nlevel, so reassigning the array (`rows = updated`) is what triggers\nthe visible repaint.\n\n## Compliance / approval queue\n\nL1 / L2 / L3 approval chain with live SLA timer, role-gated approve /\nreturn / reject buttons, immutable case-history audit panel:\n\n<div data-docs-demo=\"43-compliance-queue\" data-height=\"500\"></div>\n\nThis is the pattern for any \"this row needs a decision; some users can\nmake it, some can't\" workflow. Status changes go through a state\nmachine you own; the grid only renders the current state.\n\n## Field service dispatch\n\nDispatcher board: priority + status + tech editable inline, SLA tone\ngauge, today-timeline cell, tech capacity panel, live status stream:\n\n<div data-docs-demo=\"44-field-service\" data-height=\"500\"></div>\n\nSame `editable: (ctx) => ...` per-cell pattern for tech/status. The\ntoday-timeline cell is a positioned-bar in a single wide cell - see\nthe [pivot doc](./pivot.md) for that pattern.\n\n## Gantt chart in a grid\n\nProject plan with a wide custom Schedule cell: bars positioned by\nstart/end %, phase coloring, progress fill, today line, overdue glow:\n\n<div data-docs-demo=\"45-gantt-chart\" data-height=\"540\"></div>\n\nNo special Gantt mode - it's a single wide cell per row whose snippet\npositions absolute bars by `(start - projectStart) / projectSpan * 100`.\nAxis above the grid uses the same math.\n\n## Scheduler (single-day appointments)\n\nProviders as rows, an hour axis, click any appointment to edit it in\nthe side panel. Now-line ticks live:\n\n<div data-docs-demo=\"46-scheduler\" data-height=\"540\"></div>\n\nSame single-wide-cell pattern as the Gantt. The axis-wrap inside the\ngrid wrapper uses `ResizeObserver` so the axis width matches the\nschedule column - that's what keeps the now-line aligned between the\nheader and the cells.\n\n## Trash truck timeline (animated)\n\nPublic-works dispatcher: each truck glides along its day-long route\nwith spinning wheels, stops, and live fill levels:\n\n<div data-docs-demo=\"47-trash-truck-timeline\" data-height=\"540\"></div>\n\nThe mover uses `transition: left 220ms linear` so the new position\nanimates in. The bounce + wheel spin are CSS keyframes; no JS animation\nloop required.\n\n## CRM - sales pipeline\n\nDeal board with stage chips, weighted forecast bar, inline stage +\nprobability editing, deal detail aside with activity feed:\n\n<div data-docs-demo=\"48-crm-sales-pipeline\" data-height=\"540\"></div>\n\nThis is the canonical example of mixing inline edit + a side panel\nthat mirrors the active row.\n\n## Enterprise admin dashboard\n\nCRUD-heavy users board: inline role / status / MFA edit, bulk\nactivate / deactivate / delete, invite dialog, permissions matrix,\nlive audit log:\n\n<div data-docs-demo=\"49-admin-dashboard\" data-height=\"540\"></div>\n\nThe audit log is the recurring pattern: every mutation appends an\nentry to a `$state` array; the right-side panel renders the array.\nDelete + Invite use simple `<dialog>`-style modals.\n\n## E-commerce seller panel\n\nTabbed Amazon-seller view: catalog with SVG thumbnails, inventory bars\nvs reorder threshold, live orders pipeline, pricing rules:\n\n<div data-docs-demo=\"50-seller-panel\" data-height=\"540\"></div>\n\nThe tab strip is just chip buttons that swap which dataset / columns\nthe grid receives. One grid component, four tabs - share styling and\nbehavior without four separate mounts.\n\n## Long-form recipes\n\nThe recipes above pair a paragraph + a demo. These deeper write-ups\nhave their own pages:\n\n- [Persist column layout to URL](../recipes/persist-column-layout-to-url.md) - sort + filter + page in the URL, with debounced replaceState and restore-on-mount.\n- [Two-grid master / detail](../recipes/two-grid-master-detail.md) - one master grid drives a second detail grid; remount semantics and multi-select union.\n- [Bulk-edit selected rows](../recipes/bulk-edit-selected-rows.md) - the standard back-office workflow: pick a field, pick a value, apply to N selected rows. Includes optimistic + rollback patterns.\n- [Server-side filter with TanStack Query](../recipes/server-side-filter-with-tanstack-query.md) - caching, deduplication, cancellation, retries, optimistic edits via mutations.\n\n## See also\n\n- [Architecture overview](./architecture.md) - the layered model that\n makes these recipes composable.\n- [API reference](../reference/index.md) - every method called in the\n recipes above.\n"
3647
+ "markdown": "# Recipes / Cookbook\n\nQuick patterns for the questions that come up over and over. Each\nrecipe is paired with a live demo or a minimal snippet that runs\nagainst the shipping library.\n\n## Sort, filter, paginate at the same time\n\nThree of the most-asked features wired together against a 5k-row\ndataset. Click any header to sort, open the filter icon to filter,\nuse the pager at the bottom to walk pages:\n\n<div data-docs-demo=\"02-sort-filter-paginate\" data-height=\"440\"></div>\n\nThe implementation: register all three features in one\n`tableFeatures(...)` call and enable the matching props on `<SvGrid>`.\nSee [Filtering overview](./filtering/overview.md) and\n[Row sorting](./rows/row-sorting.md).\n\n## Select rows + copy / paste a range\n\nClick + drag selects a rectangular cell range; Ctrl/Cmd+C copies as\nTSV; pasting back from Excel writes through the same cell-write\npipeline as inline edit:\n\n<div data-docs-demo=\"04-selection-copy-paste\" data-height=\"440\"></div>\n\nThe pattern: enable `enableCellSelection={true}` and let the grid\nhandle the keyboard + paste plumbing. Cell writes go through\n`onCellValueChange` so your validator runs on every pasted value.\n\n## Bulk actions toolbar\n\nThe Gmail / Linear pattern: tick row checkboxes, a sticky toolbar\nreveals \"Mark / Delete / Copy as TSV\":\n\n<div data-docs-demo=\"23-bulk-actions\" data-height=\"440\"></div>\n\nThe trick: subscribe to `onRowSelectionChange` and render your\ntoolbar whenever the count is > 0. The grid doesn't ship a bulk-action\ncomponent on purpose - your design system's button + toast UI is\nbetter than anything we could embed.\n\n## Cascade editing (formula-like dependencies)\n\nEditing qty / price cascades into line totals + a summary card:\n\n<div data-docs-demo=\"18-cascade-editing\" data-height=\"440\"></div>\n\nIn `onCellValueChange`, after writing the cell, recompute the dependent\ncells in the same row (or in the summary state) and let Svelte 5's\nreactivity carry the update through. The pattern is essentially\n\"editable cells + a `$derived` summary\".\n\n## Validation while editing\n\nPer-column rules: invalid commits get rolled back via `setCellValue`,\nand a sidebar logs the rejection so users can audit what was tried:\n\n<div data-docs-demo=\"24-validation\" data-height=\"440\"></div>\n\nUse `onCellValueChange`: validate, and if invalid, call\n`api.setCellValue(rowIndex, columnId, oldValue)` to undo. The grid\nemits a fresh `onCellValueChange` for the undo write that your\nvalidator can recognise and skip.\n\n## Column pinning + freezing\n\nWide 13-column grid: pin Company on the left and Price on the right;\nthe middle scrolls under sticky edges:\n\n<div data-docs-demo=\"25-column-pinning\" data-height=\"440\"></div>\n\nThe user pins via the column menu - no extra wiring needed if\n`columnFilteringFeature` is registered (the menu lives on the same\nheader button). Or set the whole pinning state programmatically with\n`api.setColumnPinning({ left: ['name'], right: ['total'] })` - pass an\nempty array for a side to unpin it. `api.getColumnPinning()` reads it\nback for persistence.\n\n## List + chips editors\n\nThe two built-in multi-select editors:\n\n<div data-docs-demo=\"26-list-chips-editors\" data-height=\"440\"></div>\n\nAdd `editorType: 'list'` for a single `<select>` or `editorType: 'chips'`\nwith `editorMultiple={true}` for removable tokens. Pass static\n`editorOptions` or a `(row) => options` callback for cascading lists\n(e.g. City options depend on Country).\n\n## Spreadsheet ribbon\n\nAn Excel-style Ribbon UI driving the grid via `SvGridApi`: bold + colour\n+ number format + insert/delete row + sort, plus a live SUM/AVG/COUNT\nstatus bar:\n\n<div data-docs-demo=\"27-spreadsheet-ribbon\" data-height=\"500\"></div>\n\nThe ribbon is a regular Svelte component; every button calls\n`api.setCellValue`, `api.addRow`, `api.setSort`, etc. Status bar\nreads `getDisplayedRows()` for the live aggregate.\n\n## Master / detail\n\nA hierarchical row that expands to reveal a child grid:\n\n<div data-docs-demo=\"08-tree-and-master-detail\" data-height=\"440\"></div>\n\nSame flatten pattern as [tree rows](./rows/tree-rows.md): a flat\n`visibleRows` derivation, plus a custom cell snippet that renders the\ndetail grid inline when the row is expanded.\n\n## Forms-in-grid (master grid + side form)\n\nClick a row to load it into a tabbed detail form on the right; edits\nflow back to the grid live:\n\n<div data-docs-demo=\"40-forms-master-detail\" data-height=\"500\"></div>\n\nPattern: `onActiveCellChange` -> set `activeRowId` -> `draft = $state.snapshot(activeRow)` -> form\nbinds against `draft`. On save, write `draft` back into the rows array.\n**Use `$state.snapshot()`, not `structuredClone()`**, on Svelte 5 state\nproxies - the latter throws `DataCloneError`.\n\n## Saved views / persistence\n\nPivot-lite with chips + saved views stored in localStorage:\n\n<div data-docs-demo=\"36-reporting-workspace\" data-height=\"500\"></div>\n\nThe pattern: a `view: { groupBy, sortBy, filters, columns }` object\nthat you serialise into localStorage under a versioned key\n(`'my-app:view:v1'`). On load, hydrate the state from the saved view\nand call the matching `api.set*` methods.\n\n## Theming studio\n\nLive token playground: brand color, density, radius, font, dark/light,\nzebra. The CSS snippet at the bottom is copy-ready:\n\n<div data-docs-demo=\"37-theming-studio\" data-height=\"540\"></div>\n\nTheming is `--sg-*` custom properties - see [Tailwind integration](./tailwind.md)\nfor the full token list.\n\n## Localisation + RTL switching\n\nSix locales (en, de, fr-CA, ja, ar, he); headers, currencies, dates,\nand the grid's own scrollbar flip:\n\n<div data-docs-demo=\"38-rtl-i18n\" data-height=\"500\"></div>\n\nSet `<html dir=\"...\">` and `<html lang=\"...\">` based on the locale.\nPer-column `format: { type: 'currency', locales }` overrides the\ndocument locale for specific columns.\n\n## Print + boardroom export\n\nQuarterly P&L print pack: cover page, repeat-on-page headers, page-\nsize + orientation, CSV / HTML download, browser-native PDF:\n\n<div data-docs-demo=\"39-print-board-export\" data-height=\"500\"></div>\n\n`api.print({ title, columns, rows, orientation, pageSize })` opens a\nsandbox window with an isolated HTML document and calls `.print()` on\nit. The popup pattern is what isolates your print stylesheet from your\napp's CSS.\n\n## Healthcare EMR (role-based editing)\n\nInpatient board with vitals sparklines, risk score, code status, allergy\nchips, role-based editing (viewer / nurse / physician / admin):\n\n<div data-docs-demo=\"41-healthcare-emr\" data-height=\"500\"></div>\n\nThe role gate uses `editable: (ctx) => roleCanEdit(currentRole, ctx.column.id)`.\nThat lambda runs per-cell, so you get column + role granularity\nwithout a separate config table.\n\n## Live shipment / fleet board\n\nStreaming-style live update at 2-3 Hz with cell-level flash on change,\nETA delta colors, alert chips:\n\n<div data-docs-demo=\"42-logistics-fleet\" data-height=\"500\"></div>\n\nThe pattern: a `$state` rows array that you mutate from a\nWebSocket/SSE handler. The render component diffs at the data-array\nlevel, so reassigning the array (`rows = updated`) is what triggers\nthe visible repaint.\n\n## Compliance / approval queue\n\nL1 / L2 / L3 approval chain with live SLA timer, role-gated approve /\nreturn / reject buttons, immutable case-history audit panel:\n\n<div data-docs-demo=\"43-compliance-queue\" data-height=\"500\"></div>\n\nThis is the pattern for any \"this row needs a decision; some users can\nmake it, some can't\" workflow. Status changes go through a state\nmachine you own; the grid only renders the current state.\n\n## Field service dispatch\n\nDispatcher board: priority + status + tech editable inline, SLA tone\ngauge, today-timeline cell, tech capacity panel, live status stream:\n\n<div data-docs-demo=\"44-field-service\" data-height=\"500\"></div>\n\nSame `editable: (ctx) => ...` per-cell pattern for tech/status. The\ntoday-timeline cell is a positioned-bar in a single wide cell - see\nthe [pivot doc](./pivot.md) for that pattern.\n\n## Gantt chart in a grid\n\nProject plan with a wide custom Schedule cell: bars positioned by\nstart/end %, phase coloring, progress fill, today line, overdue glow:\n\n<div data-docs-demo=\"45-gantt-chart\" data-height=\"540\"></div>\n\nNo special Gantt mode - it's a single wide cell per row whose snippet\npositions absolute bars by `(start - projectStart) / projectSpan * 100`.\nAxis above the grid uses the same math.\n\n## Scheduler (single-day appointments)\n\nProviders as rows, an hour axis, click any appointment to edit it in\nthe side panel. Now-line ticks live:\n\n<div data-docs-demo=\"46-scheduler\" data-height=\"540\"></div>\n\nSame single-wide-cell pattern as the Gantt. The axis-wrap inside the\ngrid wrapper uses `ResizeObserver` so the axis width matches the\nschedule column - that's what keeps the now-line aligned between the\nheader and the cells.\n\n## Trash truck timeline (animated)\n\nPublic-works dispatcher: each truck glides along its day-long route\nwith spinning wheels, stops, and live fill levels:\n\n<div data-docs-demo=\"47-trash-truck-timeline\" data-height=\"540\"></div>\n\nThe mover uses `transition: left 220ms linear` so the new position\nanimates in. The bounce + wheel spin are CSS keyframes; no JS animation\nloop required.\n\n## CRM - sales pipeline\n\nDeal board with stage chips, weighted forecast bar, inline stage +\nprobability editing, deal detail aside with activity feed:\n\n<div data-docs-demo=\"48-crm-sales-pipeline\" data-height=\"540\"></div>\n\nThis is the canonical example of mixing inline edit + a side panel\nthat mirrors the active row.\n\n## Enterprise admin dashboard\n\nCRUD-heavy users board: inline role / status / MFA edit, bulk\nactivate / deactivate / delete, invite dialog, permissions matrix,\nlive audit log:\n\n<div data-docs-demo=\"49-admin-dashboard\" data-height=\"540\"></div>\n\nThe audit log is the recurring pattern: every mutation appends an\nentry to a `$state` array; the right-side panel renders the array.\nDelete + Invite use simple `<dialog>`-style modals.\n\n## E-commerce seller panel\n\nTabbed Amazon-seller view: catalog with SVG thumbnails, inventory bars\nvs reorder threshold, live orders pipeline, pricing rules:\n\n<div data-docs-demo=\"50-seller-panel\" data-height=\"540\"></div>\n\nThe tab strip is just chip buttons that swap which dataset / columns\nthe grid receives. One grid component, four tabs - share styling and\nbehavior without four separate mounts.\n\n## Long-form recipes\n\nThe recipes above pair a paragraph + a demo. These deeper write-ups\nhave their own pages:\n\n- [Persist column layout to URL](../recipes/persist-column-layout-to-url.md) - sort + filter + page in the URL, with debounced replaceState and restore-on-mount.\n- [Two-grid master / detail](../recipes/two-grid-master-detail.md) - one master grid drives a second detail grid; remount semantics and multi-select union.\n- [Bulk-edit selected rows](../recipes/bulk-edit-selected-rows.md) - the standard back-office workflow: pick a field, pick a value, apply to N selected rows. Includes optimistic + rollback patterns.\n- [Server-side filter with TanStack Query](../recipes/server-side-filter-with-tanstack-query.md) - caching, deduplication, cancellation, retries, optimistic edits via mutations.\n\n## See also\n\n- [Architecture overview](./architecture.md) - the layered model that\n makes these recipes composable.\n- [API reference](../reference/index.md) - every method called in the\n recipes above.\n"
3630
3648
  },
3631
3649
  {
3632
3650
  "slug": "help/rows/accessing-rows",
@@ -3668,7 +3686,7 @@ export const docs = [
3668
3686
  "slug": "help/rows/row-height",
3669
3687
  "path": "docs/help/rows/row-height.md",
3670
3688
  "title": "Row height",
3671
- "markdown": "# Row height\r\n\r\nRow height is a single integer in pixels.\r\n<div data-docs-demo=\"10-custom-cells-and-themes\" data-height=\"540\"></div>\r\n\r\n```svelte\r\n<SvGrid {data} {columns} features={{}} rowHeight={36} />\r\n```\r\n\r\nThe default is 36 px. The virtualizer reads `rowHeight` once and uses it to\r\ncompute the visible window and total scroll height.\r\n\r\n## Density\r\n\r\nFor a density toggle, change `rowHeight` and a matching CSS custom property:\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n let density = $state<'compact' | 'normal' | 'comfortable'>('normal')\r\n const px = $derived(density === 'compact' ? 28 : density === 'comfortable' ? 48 : 36)\r\n</script>\r\n\r\n<div style:--sg-row-height=\"{px}px\">\r\n <SvGrid {data} {columns} features={{}} rowHeight={px} />\r\n</div>\r\n```\r\n\r\nThe example gallery's\r\n[demos/10-custom-cells-and-themes.svelte](../../../examples/src/demos/10-custom-cells-and-themes.svelte)\r\nshows the density toggle in full.\r\n\r\n## Auto row height (size each row to its content)\r\n\r\n`autoRowHeight` lets cell text wrap and sizes every row to its tallest cell:\r\n\r\n```svelte\r\n<SvGrid {data} {columns} autoRowHeight />\r\n```\r\n\r\nRows are measured after they render, so this works with virtualization. Before\r\na row has been measured the grid uses `rowHeight` (or 30) as its estimate, which\r\nkeeps the scrollbar stable as you scroll into rows for the first time:\r\n\r\n```svelte\r\n<!-- 44px is the starting guess; each row settles to its real height -->\r\n<SvGrid {data} {columns} autoRowHeight rowHeight={44} />\r\n```\r\n\r\nThings worth knowing:\r\n\r\n- It costs a measurement pass per row. With uniform content a fixed `rowHeight`\r\n is cheaper - reach for `autoRowHeight` when you have free text, notes, or\r\n wrapped addresses.\r\n- Passing a **function** `rowHeight` turns it off. You are already supplying\r\n per-row heights, so measuring would fight you.\r\n- Rows re-measure when their content reflows, e.g. after a column resize.\r\n- Measurements are dropped when the row set changes, so filtering or replacing\r\n `data` never sizes a new row by the old one's content.\r\n\r\n## Variable row height (you supply the numbers)\r\n\r\nPass a function to size rows yourself, without measuring:\r\n\r\n```svelte\r\n<SvGrid {data} {columns} rowHeight={(i) => (data[i].tall ? 80 : 36)} />\r\n```\r\n\r\nThe virtualizer handles the variable-size case natively (cumulative offsets), so\r\nscrolling and the total height stay correct. The same engine is available\r\nheadless if you are building your own row layout:\r\n\r\n```ts\r\nimport { createSvelteVirtualizer } from '@svgrid/grid'\r\n\r\nconst virtualizer = createSvelteVirtualizer({\r\n count: rows.length,\r\n estimateSize: (index) => rows[index]!.tall ? 80 : 36,\r\n viewportHeight: scrollEl.clientHeight,\r\n overscan: 6,\r\n})\r\n\r\n// It owns no DOM. Feed it your scroller's numbers as they change:\r\nvirtualizer.setScrollOffset(scrollEl.scrollTop)\r\nvirtualizer.setViewportHeight(scrollEl.clientHeight)\r\n```\r\n\r\nSee [`packages/grid/src/virtualization/`](../../../packages/grid/src/virtualization/).\r\n\r\n## Header height\r\n\r\nHeader height is independent of row height. See\r\n[Column headers](../columns/column-headers.md) for how to size it.\r\n\r\n## Row-number column width\r\n\r\nWhen `showRowNumbers={true}`, the leading row-number column defaults\r\nto **56 px**, which fits up to `99,999`. For larger datasets, bump\r\nthe width via `rowNumberWidth`:\r\n\r\n```svelte\r\n<!-- One million rows: \"1,000,000\" needs ~ 92 px to stay fully visible -->\r\n<SvGrid\r\n {data}\r\n {columns}\r\n features={{}}\r\n showRowNumbers={true}\r\n rowNumberWidth={92}\r\n rowHeight={18}\r\n virtualization={true}\r\n containerHeight=\"100%\"\r\n/>\r\n```\r\n\r\nRule of thumb: budget ~ 8 px per digit plus 14 px of padding. So:\r\n\r\n| Row count | Largest number | Suggested `rowNumberWidth` |\r\n|--------------|----------------|----------------------------|\r\n| < 1 000 | \"999\" | `40` |\r\n| < 100 000 | \"99,999\" | `56` (default) |\r\n| < 10 000 000 | \"9,999,999\" | `92` |\r\n\r\nDemo 78 (\"1 million rows\") uses 92 px so the millionth row's index\r\nstays legible at the bottom of the scroll.\r\n\r\n## See also\r\n\r\n- [Row pinning](./row-pinning.md)\r\n- [Styling rows](./styling-rows.md)\r\n- [Demo 78 - 1 million rows](../../../examples/src/demos/78-million-rows.svelte)\r\n"
3689
+ "markdown": "# Row height\r\n\r\nRow height is a single integer in pixels.\r\n<div data-docs-demo=\"10-custom-cells-and-themes\" data-height=\"540\"></div>\r\n\r\n```svelte\r\n<SvGrid {data} {columns} features={{}} rowHeight={36} />\r\n```\r\n\r\nThe default is 30 px. The virtualizer reads `rowHeight` and uses it to\r\ncompute the visible window and total scroll height.\r\n\r\nBecause the virtualizer needs the height as a number up front, row\r\nheight is a prop rather than a CSS token, and the grid writes it as an\r\ninline style on each row. A stylesheet rule cannot set it.\r\n\r\n## Density\r\n\r\nA density toggle is just a derived `rowHeight`:\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n let density = $state<'compact' | 'normal' | 'comfortable'>('normal')\r\n const px = $derived(density === 'compact' ? 28 : density === 'comfortable' ? 48 : 30)\r\n</script>\r\n\r\n<SvGrid {data} {columns} features={{}} rowHeight={px} />\r\n```\r\n\r\nThe example gallery's\r\n[demos/10-custom-cells-and-themes.svelte](../../../examples/src/demos/10-custom-cells-and-themes.svelte)\r\nshows the density toggle in full.\r\n\r\n## Auto row height (size each row to its content)\r\n\r\n`autoRowHeight` lets cell text wrap and sizes every row to its tallest cell:\r\n\r\n```svelte\r\n<SvGrid {data} {columns} autoRowHeight />\r\n```\r\n\r\nRows are measured after they render, so this works with virtualization. Before\r\na row has been measured the grid uses `rowHeight` (or 30) as its estimate, which\r\nkeeps the scrollbar stable as you scroll into rows for the first time:\r\n\r\n```svelte\r\n<!-- 44px is the starting guess; each row settles to its real height -->\r\n<SvGrid {data} {columns} autoRowHeight rowHeight={44} />\r\n```\r\n\r\nThings worth knowing:\r\n\r\n- It costs a measurement pass per row. With uniform content a fixed `rowHeight`\r\n is cheaper - reach for `autoRowHeight` when you have free text, notes, or\r\n wrapped addresses.\r\n- Passing a **function** `rowHeight` turns it off. You are already supplying\r\n per-row heights, so measuring would fight you.\r\n- Rows re-measure when their content reflows, e.g. after a column resize.\r\n- Measurements are dropped when the row set changes, so filtering or replacing\r\n `data` never sizes a new row by the old one's content.\r\n\r\n## Variable row height (you supply the numbers)\r\n\r\nPass a function to size rows yourself, without measuring:\r\n\r\n```svelte\r\n<SvGrid {data} {columns} rowHeight={(i) => (data[i].tall ? 80 : 36)} />\r\n```\r\n\r\nThe virtualizer handles the variable-size case natively (cumulative offsets), so\r\nscrolling and the total height stay correct. The same engine is available\r\nheadless if you are building your own row layout:\r\n\r\n```ts\r\nimport { createSvelteVirtualizer } from '@svgrid/grid'\r\n\r\nconst virtualizer = createSvelteVirtualizer({\r\n count: rows.length,\r\n estimateSize: (index) => rows[index]!.tall ? 80 : 36,\r\n viewportHeight: scrollEl.clientHeight,\r\n overscan: 6,\r\n})\r\n\r\n// It owns no DOM. Feed it your scroller's numbers as they change:\r\nvirtualizer.setScrollOffset(scrollEl.scrollTop)\r\nvirtualizer.setViewportHeight(scrollEl.clientHeight)\r\n```\r\n\r\nSee [`packages/grid/src/virtualization/`](../../../packages/grid/src/virtualization/).\r\n\r\n## Header height\r\n\r\nHeader height is independent of row height. See\r\n[Column headers](../columns/column-headers.md) for how to size it.\r\n\r\n## Row-number column width\r\n\r\nWhen `showRowNumbers={true}`, the leading row-number column defaults\r\nto **56 px**, which fits up to `99,999`. For larger datasets, bump\r\nthe width via `rowNumberWidth`:\r\n\r\n```svelte\r\n<!-- One million rows: \"1,000,000\" needs ~ 92 px to stay fully visible -->\r\n<SvGrid\r\n {data}\r\n {columns}\r\n features={{}}\r\n showRowNumbers={true}\r\n rowNumberWidth={92}\r\n rowHeight={18}\r\n virtualization={true}\r\n containerHeight=\"100%\"\r\n/>\r\n```\r\n\r\nRule of thumb: budget ~ 8 px per digit plus 14 px of padding. So:\r\n\r\n| Row count | Largest number | Suggested `rowNumberWidth` |\r\n|--------------|----------------|----------------------------|\r\n| < 1 000 | \"999\" | `40` |\r\n| < 100 000 | \"99,999\" | `56` (default) |\r\n| < 10 000 000 | \"9,999,999\" | `92` |\r\n\r\nDemo 78 (\"1 million rows\") uses 92 px so the millionth row's index\r\nstays legible at the bottom of the scroll.\r\n\r\n## See also\r\n\r\n- [Row pinning](./row-pinning.md)\r\n- [Styling rows](./styling-rows.md)\r\n- [Demo 78 - 1 million rows](../../../examples/src/demos/78-million-rows.svelte)\r\n"
3672
3690
  },
3673
3691
  {
3674
3692
  "slug": "help/rows/row-pagination",
@@ -3704,7 +3722,7 @@ export const docs = [
3704
3722
  "slug": "help/rows/styling-rows",
3705
3723
  "path": "docs/help/rows/styling-rows.md",
3706
3724
  "title": "Styling rows",
3707
- "markdown": "# Styling rows\n\nRows are `<tr role=\"row\">` elements inside the grid table. Style them with\nplain CSS.\n<div data-docs-demo=\"62-conditional-styling\" data-height=\"540\"></div>\n\n## Zebra striping\n\n```css\ntable[role='grid'] tbody tr:nth-child(even) {\n background: var(--sg-row-alt-bg);\n}\n```\n\n## Hover\n\n```css\ntable[role='grid'] tbody tr:hover {\n background: var(--sg-row-hover-bg);\n}\n```\n\n## Selection\n\nA selected row carries `aria-selected=\"true\"`:\n\n```css\ntable[role='grid'] tbody tr[aria-selected='true'] {\n background: var(--sg-selection-bg);\n}\n```\n\n## Conditional row styling\n\n`<SvGrid>` accepts a `rowClass` callback. Return a string, an array\nof strings, or a `Record<string, boolean>`, and the classes are\nadded to the `<tr>`:\n\n```svelte\n<SvGrid\n data={rows}\n {columns}\n features={features}\n rowClass={({ row }) => ({\n 'is-overdue': row.dueDate < new Date().toISOString().slice(0, 10),\n 'is-cancelled': row.status === 'cancelled',\n })}\n/>\n\n<style>\n :global(tr.is-overdue .sv-grid-cell) { background: rgba(220, 38, 38, 0.06); }\n :global(tr.is-cancelled .sv-grid-cell) { color: var(--sg-muted); text-decoration: line-through; }\n</style>\n```\n\nThe callback receives `{ row, rowIndex }` - the un-mutated source\nrow + its data-array index. Runs per visible row on every render, so\nkeep the body cheap (string lookups, equality checks - no `.find()`\nover the whole dataset).\n\nFor one-cell tints, use `cellClass` on the column def - same shape,\ncalled per cell with the standard `CellContext`. See\n[Styling cells](../cells/styling-cells.md).\n\n## CSS custom properties\n\nThe gallery defines these tokens - override at `:root` or on the grid host:\n\n```\n--sg-bg grid background\n--sg-fg grid foreground\n--sg-border cell borders\n--sg-header-bg header background\n--sg-header-fg header foreground\n--sg-row-alt-bg even-row background\n--sg-row-hover-bg hover background\n--sg-selection-bg selected-row background\n--sg-row-height row height (also pass `rowHeight` prop)\n--sg-focus-ring focus outline (box-shadow)\n--sg-accent primary accent (sort arrow, etc)\n```\n\n## See also\n\n- [Row height](./row-height.md)\n- [Custom cells](../cells/cell-components.md)\n"
3725
+ "markdown": "# Styling rows\n\nRows are `<tr role=\"row\">` elements inside the grid table. Style them with\nplain CSS.\n<div data-docs-demo=\"62-conditional-styling\" data-height=\"540\"></div>\n\n## Zebra striping\n\n```css\ntable[role='grid'] tbody tr:nth-child(even) {\n background: var(--sg-row-alt-bg);\n}\n```\n\n## Hover\n\n```css\ntable[role='grid'] tbody tr:hover {\n background: var(--sg-row-hover-bg);\n}\n```\n\n## Selection\n\nA selected row carries `aria-selected=\"true\"`:\n\n```css\ntable[role='grid'] tbody tr[aria-selected='true'] {\n background: var(--sg-selection-bg);\n}\n```\n\n## Conditional row styling\n\n`<SvGrid>` accepts a `rowClass` callback. Return a string, an array\nof strings, or a `Record<string, boolean>`, and the classes are\nadded to the `<tr>`:\n\n```svelte\n<SvGrid\n data={rows}\n {columns}\n features={features}\n rowClass={({ row }) => ({\n 'is-overdue': row.dueDate < new Date().toISOString().slice(0, 10),\n 'is-cancelled': row.status === 'cancelled',\n })}\n/>\n\n<style>\n :global(tr.is-overdue .sv-grid-cell) { background: rgba(220, 38, 38, 0.06); }\n :global(tr.is-cancelled .sv-grid-cell) { color: var(--sg-muted); text-decoration: line-through; }\n</style>\n```\n\nThe callback receives `{ row, rowIndex }` - the un-mutated source\nrow + its data-array index. Runs per visible row on every render, so\nkeep the body cheap (string lookups, equality checks - no `.find()`\nover the whole dataset).\n\nFor one-cell tints, use `cellClass` on the column def - same shape,\ncalled per cell with the standard `CellContext`. See\n[Styling cells](../cells/styling-cells.md).\n\n## CSS custom properties\n\nThe gallery defines these tokens - override at `:root` or on the grid host:\n\n```\n--sg-bg grid background\n--sg-fg grid foreground\n--sg-border cell borders\n--sg-header-bg header background\n--sg-header-fg header foreground\n--sg-row-alt-bg even-row background\n--sg-row-hover-bg hover background\n--sg-selection-bg selected-row background\n--sg-focus-ring focus outline (box-shadow)\n--sg-accent primary accent (sort arrow, etc)\n```\n\n## See also\n\n- [Row height](./row-height.md)\n- [Custom cells](../cells/cell-components.md)\n"
3708
3726
  },
3709
3727
  {
3710
3728
  "slug": "help/rows/transactions",
@@ -3740,7 +3758,7 @@ export const docs = [
3740
3758
  "slug": "help/security",
3741
3759
  "path": "docs/help/security.md",
3742
3760
  "title": "Security & supply chain",
3743
- "markdown": "# Security & supply chain\r\n\r\nSvGrid is built for shipping into regulated environments where every\r\ndependency, every runtime call, and every network egress has to be\r\njustified before procurement signs. This page is the answer to \"what\r\ndoes this package do, what does it depend on, and what would I tell\r\nour InfoSec team?\".\r\n\r\n![The SvGrid library renders and holds state inside the browser and opens no connection of its own - no fetch, no telemetry; only your app code crosses the boundary to the network to fetch rows and persist changes.](/docs-media/grid-security.svg)\r\n\r\n<div data-docs-demo=\"16-csp-compliant\" data-height=\"540\"></div>\r\n\r\n## TL;DR\r\n\r\n| Property | Status |\r\n| --------------------------------- | ---------------------------------------------------------------------- |\r\n| Network egress at runtime | **None.** Zero analytics, zero phone-home, no automatic update checks. |\r\n| Telemetry | **Zero.** No `fetch`, no `navigator.sendBeacon`, no console identifiers. |\r\n| `eval` / `new Function` / dynamic code | **None in `@svgrid/grid`.** See [CSP-compliant grid](https://svgrid.com/demos/16-csp-compliant/) demo + runtime self-check. |\r\n| Cookies / localStorage | None set by the library itself. (Your app's saved-views helpers may opt in.) |\r\n| Outbound dependencies | **Community:** zero runtime deps. **Enterprise:** two optional peer deps (`jszip`, `pdfmake`), see below. |\r\n| AI calls | The user's own `AIProvider` adapter calls whichever endpoint *they* configured. The package never embeds a model client. |\r\n| License | Community: MIT. Enterprise: commercial (see [LICENSE](../../packages/enterprise/LICENSE)). |\r\n| npm provenance + signing | Published with `npm publish --provenance` from GitHub Actions; integrity hashes in the npm registry. |\r\n| Source | 100% open source. `pnpm patch` works; everything in this monorepo is the same code that ships to npm. |\r\n\r\n## Runtime dependencies\r\n\r\n`@svgrid/grid` has **zero runtime dependencies**. It is a single\r\npackage with no transitive supply chain - the only thing the user's\r\nbuild pulls in is `svelte` itself (peer).\r\n\r\n`@svgrid/enterprise` adds two **optional** peer dependencies, both lazy-loaded\r\non first use:\r\n\r\n| Peer | When loaded | License | Why optional |\r\n| --------------- | -------------------------------------------- | -------------- | ---------------------------------------------------------------- |\r\n| `jszip` ^3.10 | `api.exportData({ format: 'xlsx' })` *or* `api.importData({ format: 'xlsx' })` | MIT | If you don't export/import Excel, you don't install this peer. |\r\n| `pdfmake` ^0.2 | `api.exportData({ format: 'pdf' })` | MIT | If you don't export to PDF, you don't install this peer. |\r\n\r\nNeither is bundled. Both are dynamically `import()`-ed at first call. If\r\nthe consumer hasn't installed the peer, the call throws a typed error\r\nnaming the missing module and the install command - the rest of the\r\nlibrary keeps working.\r\n\r\nCSV, TSV, HTML, JSON import/export, print, the AI assistant, and the\r\npivot helpers have **no extra peer dependencies** at all.\r\n\r\n## What the package does at runtime\r\n\r\nEvery observable side effect, mapped:\r\n\r\n| Surface | Side effect |\r\n| ------------------------------------ | -------------------------------------------------------------------------- |\r\n| `<SvGrid>` mount | Sets up `requestAnimationFrame`, `ResizeObserver`, and DOM event listeners on the grid container. |\r\n| Inline editing | Reads + writes `document` selection; commits via your `onCellValueChange`. |\r\n| Export to xlsx / pdf / csv / tsv / html | Creates a `Blob` and triggers a download via a synthetic `<a download>`. No network. |\r\n| Import from xlsx / csv / tsv / json | Reads the user-provided `File` via `arrayBuffer()` / `text()`. No network. |\r\n| Print | Opens a sandbox popup window with an isolated HTML document and calls its `print()`. |\r\n| AI helpers | Build a prompt locally and call the consumer-registered `AIProvider`. The grid itself never opens a connection. |\r\n| License key check | A 4-line string-prefix check against an in-memory revoked-key set. No network. |\r\n| Unlicensed watermark | Renders a small DOM badge linking to the pricing page. No network. |\r\n| Unlicensed upgrade prompt | On first unlicensed Enterprise feature call, appends a one-time DOM card linking to a trial. No network, no storage; one in-memory flag. |\r\n\r\n## CSP guidance\r\n\r\nThe community grid is CSP-friendly. A strict policy that works:\r\n\r\n```\r\ndefault-src 'self';\r\nscript-src 'self';\r\nstyle-src 'self' 'unsafe-inline';\r\nimg-src 'self' data:;\r\nconnect-src 'self';\r\n```\r\n\r\nNotes:\r\n\r\n- `'unsafe-inline'` on `style-src` is needed because Svelte 5 emits\r\n scoped style attributes inline. You can drop it if you switch to\r\n `unsafe-hashes` with a CSP nonce-aware build of Svelte.\r\n- `connect-src 'self'` is enough - the grid never opens a connection to\r\n a third-party origin.\r\n- `script-src` does NOT need `'unsafe-eval'` for community. (Enterprise pulls\r\n in `pdfmake` which historically used `eval`; check the pdfmake version\r\n if your CSP is strict.)\r\n- Demo [16. CSP-compliant grid](https://svgrid.com/demos/16-csp-compliant/) runs the grid under a CSP header and surfaces any violation in real time.\r\n\r\n## Source provenance\r\n\r\n- Source repository: `github.com/sv-grid/sv-grid` - all releases tagged.\r\n- Build pipeline: GitHub Actions. The release workflow signs and\r\n publishes to npm with `--provenance`. You can verify the signature\r\n via `npm audit signatures` (npm 9+) or by inspecting the\r\n package metadata on the registry.\r\n- No private patches. The code on npm is the code in the public\r\n monorepo at the tagged commit.\r\n\r\n## Vulnerability handling\r\n\r\n- Reports: open a draft security advisory on the GitHub repository, or\r\n email `support@jqwidgets.com`. PGP key on the repository's SECURITY.md.\r\n- Response SLA (paid customers): acknowledgement within one business\r\n day, advisory + patch within five business days for severity High +\r\n Critical.\r\n- Community: best-effort but every report is triaged.\r\n- Public advisories for Enterprise customers go out via the support Slack +\r\n email **before** the GitHub advisory page goes public.\r\n\r\n## SBOM\r\n\r\nEvery release ships an `npm pack` tarball. You can generate a CycloneDX\r\nor SPDX SBOM directly:\r\n\r\n```bash\r\nnpx @cyclonedx/cdxgen -t npm -o sbom.json @svgrid/grid\r\n# or\r\nnpx @cyclonedx/cdxgen -t npm -o sbom.json @svgrid/enterprise\r\n```\r\n\r\nFor Enterprise, the SBOM lists `jszip` and `pdfmake` as optional peers - flag\r\nto your scanner if you don't use those formats.\r\n\r\n## Data residency\r\n\r\nThe library processes data **in the browser**. Nothing leaves the user's\r\nmachine via the package itself.\r\n\r\nIf you wire `setAIProvider(fn)` to a remote endpoint, the prompt + row\r\nsample the helpers build (a column schema + at most ~25 sampled rows)\r\nis what goes through your adapter. The package gives you the prompt\r\nverbatim before sending - you decide whether to redact, route to an\r\non-prem model, or hash sensitive fields.\r\n\r\n## Audit-friendly defaults\r\n\r\n- **No global state** beyond a few module-scoped variables for the\r\n license + AI provider registration. No singletons that survive HMR.\r\n- **Pure-function helpers** for filtering / sorting / aggregation -\r\n every callable you import is testable in isolation (vitest suite\r\n proves this for 1000+ assertions).\r\n- **No prototype pollution surface.** Every helper uses\r\n `Object.create(null)` or own-property maps; no untyped object\r\n merging.\r\n- **No reflection-driven config.** Column definitions are plain\r\n objects; the grid never reads metadata via `eval` or `with`.\r\n\r\n## See also\r\n\r\n- [Browser support](./browser-support.md) - tested target matrix.\r\n- [Testing and quality](./testing-and-quality.md) - the test suite that\r\n underwrites this page's claims.\r\n- [API stability](./api-stability.md) - semver policy, deprecation\r\n lifecycle.\r\n\r\n## Frequently asked questions\r\n\r\n### Is SvGrid safe to use in a regulated or enterprise environment?\r\n\r\nYes. SvGrid is a client-side library: it makes no network calls of its own,\r\nsends no telemetry, and runs CSP-clean (no `eval`, no `new Function`, no inline\r\nscripts). All data stays in the browser, so it does not change your app's data\r\negress posture.\r\n\r\n### Does SvGrid send any telemetry or phone home?\r\n\r\nNo. There is zero outbound traffic from the library. Any network calls in your\r\napp are ones you write.\r\n\r\n### What is SvGrid's supply-chain footprint?\r\n\r\nThe Community core has a minimal dependency surface; Enterprise export/import features\r\nlazy-load their dependencies only when used. See this page for the full\r\ndependency and runtime-call accounting procurement asks for.\r\n"
3761
+ "markdown": "# Security & supply chain\r\n\r\nSvGrid is built for shipping into regulated environments where every\r\ndependency, every runtime call, and every network egress has to be\r\njustified before procurement signs. This page is the answer to \"what\r\ndoes this package do, what does it depend on, and what would I tell\r\nour InfoSec team?\".\r\n\r\n![The SvGrid library renders and holds state inside the browser and opens no connection of its own - no fetch, no telemetry; only your app code crosses the boundary to the network to fetch rows and persist changes.](/docs-media/grid-security.svg)\r\n\r\n<div data-docs-demo=\"16-csp-compliant\" data-height=\"540\"></div>\r\n\r\n## TL;DR\r\n\r\n| Property | Status |\r\n| --------------------------------- | ---------------------------------------------------------------------- |\r\n| Network egress at runtime | **None.** Zero analytics, zero phone-home, no automatic update checks. |\r\n| Telemetry | **Zero.** No `fetch`, no `navigator.sendBeacon`, no console identifiers. |\r\n| `eval` / `new Function` / dynamic code | **None in `@svgrid/grid`.** See [CSP-compliant grid](https://svgrid.com/demos/16-csp-compliant/) demo + runtime self-check. |\r\n| Cookies / localStorage | None set by the library itself. (Your app's saved-views helpers may opt in.) |\r\n| Outbound dependencies | **Community:** zero runtime deps. **Enterprise:** two optional peer deps (`jszip`, `pdfmake`), see below. |\r\n| AI calls | The user's own `AIProvider` adapter calls whichever endpoint *they* configured. The package never embeds a model client. |\r\n| License | Community: MIT. Enterprise: commercial (see [LICENSE](../../packages/enterprise/LICENSE)). |\r\n| npm provenance + signing | Published with `npm publish --provenance` from GitHub Actions; integrity hashes in the npm registry. |\r\n| Source | 100% open source. `pnpm patch` works; everything in this monorepo is the same code that ships to npm. |\r\n\r\n## Runtime dependencies\r\n\r\n`@svgrid/grid` has **zero runtime dependencies**. It is a single\r\npackage with no transitive supply chain - the only thing the user's\r\nbuild pulls in is `svelte` itself (peer).\r\n\r\n`@svgrid/enterprise` adds two **optional** peer dependencies, both lazy-loaded\r\non first use:\r\n\r\n| Peer | When loaded | License | Why optional |\r\n| --------------- | -------------------------------------------- | -------------- | ---------------------------------------------------------------- |\r\n| `jszip` ^3.10 | `api.exportData({ format: 'xlsx' })` *or* `api.importData({ format: 'xlsx' })` | MIT | If you don't export/import Excel, you don't install this peer. |\r\n| `pdfmake` ^0.2 | `api.exportData({ format: 'pdf' })` | MIT | If you don't export to PDF, you don't install this peer. |\r\n\r\nNeither is bundled. Both are dynamically `import()`-ed at first call. If\r\nthe consumer hasn't installed the peer, the call throws a typed error\r\nnaming the missing module and the install command - the rest of the\r\nlibrary keeps working.\r\n\r\nCSV, TSV, HTML, JSON import/export, print, the AI assistant, and the\r\npivot helpers have **no extra peer dependencies** at all.\r\n\r\n## What the package does at runtime\r\n\r\nEvery observable side effect, mapped:\r\n\r\n| Surface | Side effect |\r\n| ------------------------------------ | -------------------------------------------------------------------------- |\r\n| `<SvGrid>` mount | Sets up `requestAnimationFrame`, `ResizeObserver`, and DOM event listeners on the grid container. |\r\n| Inline editing | Reads + writes `document` selection; commits via your `onCellValueChange`. |\r\n| Export to xlsx / pdf / csv / tsv / html | Creates a `Blob` and triggers a download via a synthetic `<a download>`. No network. |\r\n| Import from xlsx / csv / tsv / json | Reads the user-provided `File` via `arrayBuffer()` / `text()`. No network. |\r\n| Print | Opens a sandbox popup window with an isolated HTML document and calls its `print()`. |\r\n| AI helpers | Build a prompt locally and call the consumer-registered `AIProvider`. The grid itself never opens a connection. |\r\n| License key check | A 4-line string-prefix check against an in-memory revoked-key set. No network. |\r\n| Unlicensed watermark | Renders a small DOM badge linking to the pricing page. No network. |\r\n| Unlicensed upgrade prompt | On first unlicensed Enterprise feature call, appends a one-time DOM card linking to a trial. No network, no storage; one in-memory flag. |\r\n\r\n## CSP guidance\r\n\r\nThe community grid is CSP-friendly. A strict policy that works:\r\n\r\n```\r\ndefault-src 'self';\r\nscript-src 'self';\r\nstyle-src 'self' 'unsafe-inline';\r\nimg-src 'self' data:;\r\nconnect-src 'self';\r\n```\r\n\r\nNotes:\r\n\r\n- `'unsafe-inline'` on `style-src` is needed because Svelte 5 emits\r\n scoped style attributes inline. You can drop it if you switch to\r\n `unsafe-hashes` with a CSP nonce-aware build of Svelte.\r\n- `connect-src 'self'` is enough - the grid never opens a connection to\r\n a third-party origin.\r\n- `script-src` does NOT need `'unsafe-eval'` for community. (Enterprise pulls\r\n in `pdfmake` which historically used `eval`; check the pdfmake version\r\n if your CSP is strict.)\r\n- Demo [16. CSP-compliant grid](https://svgrid.com/demos/16-csp-compliant/) runs the grid under a CSP header and surfaces any violation in real time.\r\n\r\n## Source provenance\r\n\r\n- Source repository: `github.com/sv-grid/sv-grid` - all releases tagged.\r\n- Build pipeline: GitHub Actions. The release workflow signs and\r\n publishes to npm with `--provenance`. You can verify the signature\r\n via `npm audit signatures` (npm 9+) or by inspecting the\r\n package metadata on the registry.\r\n- No private patches. The code on npm is the code in the public\r\n monorepo at the tagged commit.\r\n\r\n## Vulnerability handling\r\n\r\n- Reports: open a draft security advisory on the GitHub repository, or\r\n email `support@jqwidgets.com`. PGP key on the repository's SECURITY.md.\r\n- Response SLA (paid customers): acknowledgement within one business\r\n day, advisory + patch within five business days for severity High +\r\n Critical.\r\n- Community: best-effort but every report is triaged.\r\n- Public advisories for Enterprise customers go out via the support Slack +\r\n email **before** the GitHub advisory page goes public.\r\n\r\n## SBOM\r\n\r\nWe publish a **CycloneDX 1.6** document per package in\r\n[`sbom/`](https://github.com/sv-grid/sv-grid/tree/main/sbom), covering runtime\r\nand peer dependencies to full declared depth. Fetch the one you need straight\r\nfrom the repo, or regenerate from a clone:\r\n\r\n```bash\r\npnpm sbom # write sbom/*.cdx.json\r\npnpm sbom:check # non-zero exit if they have drifted from the manifests\r\n```\r\n\r\nIf your process prefers a scanner-produced document, or you want SPDX instead,\r\ngenerate one against your own installed tree:\r\n\r\n```bash\r\nnpx @cyclonedx/cdxgen -t npm -o sbom.json\r\n```\r\n\r\nFor Enterprise, the SBOM lists `jszip` and `pdfmake` as optional peers - flag\r\nto your scanner if you don't use those formats.\r\n\r\n## Data residency\r\n\r\nThe library processes data **in the browser**. Nothing leaves the user's\r\nmachine via the package itself.\r\n\r\nIf you wire `setAIProvider(fn)` to a remote endpoint, the prompt + row\r\nsample the helpers build (a column schema + at most ~25 sampled rows)\r\nis what goes through your adapter. The package gives you the prompt\r\nverbatim before sending - you decide whether to redact, route to an\r\non-prem model, or hash sensitive fields.\r\n\r\n## Audit-friendly defaults\r\n\r\n- **No global state** beyond a few module-scoped variables for the\r\n license + AI provider registration. No singletons that survive HMR.\r\n- **Pure-function helpers** for filtering / sorting / aggregation -\r\n every callable you import is testable in isolation (vitest suite\r\n proves this for 1000+ assertions).\r\n- **No prototype pollution surface.** Every helper uses\r\n `Object.create(null)` or own-property maps; no untyped object\r\n merging.\r\n- **No reflection-driven config.** Column definitions are plain\r\n objects; the grid never reads metadata via `eval` or `with`.\r\n\r\n## See also\r\n\r\n- [Browser support](./browser-support.md) - tested target matrix.\r\n- [Testing and quality](./testing-and-quality.md) - the test suite that\r\n underwrites this page's claims.\r\n- [API stability](./api-stability.md) - semver policy, deprecation\r\n lifecycle.\r\n\r\n## Frequently asked questions\r\n\r\n### Is SvGrid safe to use in a regulated or enterprise environment?\r\n\r\nYes. SvGrid is a client-side library: it makes no network calls of its own,\r\nsends no telemetry, and runs CSP-clean (no `eval`, no `new Function`, no inline\r\nscripts). All data stays in the browser, so it does not change your app's data\r\negress posture.\r\n\r\n### Does SvGrid send any telemetry or phone home?\r\n\r\nNo. There is zero outbound traffic from the library. Any network calls in your\r\napp are ones you write.\r\n\r\n### What is SvGrid's supply-chain footprint?\r\n\r\nThe Community core has a minimal dependency surface; Enterprise export/import features\r\nlazy-load their dependencies only when used. See this page for the full\r\ndependency and runtime-call accounting procurement asks for.\r\n"
3744
3762
  },
3745
3763
  {
3746
3764
  "slug": "help/server-side-data",
@@ -3758,13 +3776,13 @@ export const docs = [
3758
3776
  "slug": "help/server/server-filtering",
3759
3777
  "path": "docs/help/server/server-filtering.md",
3760
3778
  "title": "Server filtering",
3761
- "markdown": "# Server filtering\n\nWhen the data lives on the server, the grid does not filter rows itself. It\nrecords what the user typed and emits a single **`ServerFilterModel`**, and your\nbackend turns that model into a `WHERE` clause. This page is a deep dive into\nthat model: its exact shape, the operator set, set-filter faceting, the global\nquick search, and how to map all of it to a **parameterized** query with the\n`normalizeFilters` helper from `@svgrid/enterprise`.\n\nIt builds on the [Server-Side Row Model](./server-row-model.md), where\n`createServerDataSource` owns the request lifecycle.\n\n![Filter inputs and a quick-search box collapse into one ServerFilterModel with global and columns, which normalizeFilters turns into parameterized WHERE predicates that fetch the filtered page from the server.](/docs-media/server-filtering.svg)\n\n## The `ServerFilterModel` shape\n\nEvery `getRows(request)` call receives the current filter as\n`request.filterModel`. It has two parts: a `global` quick-search string and a\n`columns` map keyed by column id.\n\n```ts\ntype ServerFilterModel = {\n global?: string // the quick-filter search box\n columns?: Record<string, { // keyed by column id\n operator: string // equals | contains | startsWith | greaterThan | lessThan | between | isBlank\n value: string\n valueTo?: string // second bound, for `between`\n selectedValues?: string[] // set-filter (facet checklist) selection\n }>\n}\n```\n\nA populated model:\n\n```json\n{\n \"global\": \"berlin\",\n \"columns\": {\n \"status\": { \"operator\": \"equals\", \"value\": \"active\" },\n \"age\": { \"operator\": \"between\", \"value\": \"18\", \"valueTo\": \"65\" },\n \"country\": { \"operator\": \"contains\", \"value\": \"\", \"selectedValues\": [\"DE\", \"FR\"] }\n }\n}\n```\n\nEach entry may carry an operator-style filter (`value` plus, for `between`, a\n`valueTo`) **or** a set-filter selection (`selectedValues`), or both. When\n`selectedValues` is present it wins - the checklist selection takes precedence\nover the operator value.\n\n## The operator set\n\n`operator` is one of seven values. Map each to a predicate:\n\n| `operator` | SQL |\n| -------------- | ------------------------------------- |\n| `equals` | `col = $value` |\n| `contains` | `col ILIKE '%' || $value || '%'` |\n| `startsWith` | `col ILIKE $value || '%'` |\n| `greaterThan` | `col > $value` |\n| `lessThan` | `col < $value` |\n| `between` | `col BETWEEN $value AND $valueTo` |\n| `isBlank` | `col IS NULL OR col = ''` |\n\nAny unrecognized operator is treated as `contains` - the safe, permissive\ndefault.\n\n## Set filters and faceting\n\nA set filter (facet checklist) is expressed with `selectedValues`: the list of\nvalues the user ticked. It maps to an `IN (...)` predicate:\n\n```sql\ncol IN ($v0, $v1, $v2) -- one bound parameter per selected value\n```\n\nBecause `selectedValues` takes precedence over `operator` / `value`, a column\nthat has both a checklist selection and a typed value filters by the checklist.\nBuild the facet list itself with a separate `SELECT DISTINCT col` (or a\npre-computed facet count) query - the model carries only the selection, not the\navailable options.\n\n## The global quick filter\n\n`global` is the free-text quick-search box. It is not scoped to one column: it\nis an **`OR` across your searchable columns**. You decide which columns are\nsearchable.\n\n```sql\n-- global = 'berlin'\n(name ILIKE '%' || $q || '%' OR city ILIKE '%' || $q || '%' OR country ILIKE '%' || $q || '%')\n```\n\nCombine the global `OR` group with the per-column predicates using `AND`: a row\nmust match the quick search **and** every active column filter.\n\n## Mapping to a parameterized WHERE\n\nThe one rule that matters: **never string-concatenate user values into SQL.**\nBind every value as a parameter so a value like `'; DROP TABLE ...` is data, not\ncode. The `IN (...)` list gets one placeholder per selected value; `between`\ngets two.\n\nYou do not have to hand-write the operator switch. `@svgrid/enterprise` ships\n`normalizeFilters(model)`, which flattens the model into one uniform list of\npredicates plus the trimmed search term - the same helper the built-in REST and\nSQL sources use.\n\n```ts\nimport { normalizeFilters } from '@svgrid/enterprise'\n\nconst { predicates, search } = normalizeFilters(filterModel)\n// predicates: Array of backend-neutral predicates over one column each -\n// { column, op: 'in', values } // set filter\n// { column, op: 'isNull' } // isBlank\n// { column, op: 'contains' | 'startsWith' | 'eq' | 'gt' | 'lt', value }\n// { column, op: 'between', value, valueTo }\n// search: the trimmed global term (or undefined)\n```\n\n`normalizeFilters` also does the tidying you would otherwise repeat in every\nbackend: it drops empty operator filters, trims values, prefers `selectedValues`\nwhen present, and for `between` fills a missing bound from the other. Turning\nthat neutral list into bound SQL is then a small, safe switch:\n\n```ts\nfunction buildWhere(filterModel, searchable) {\n const { predicates, search } = normalizeFilters(filterModel)\n const clauses = []\n const params = []\n\n for (const p of predicates) {\n switch (p.op) {\n case 'in': {\n // one bound placeholder per selected value\n const start = params.length\n p.values.forEach((v) => params.push(v))\n const list = p.values.map((_, i) => `$${start + i + 1}`).join(', ')\n clauses.push(`${p.column} IN (${list})`)\n break\n }\n case 'isNull': clauses.push(`(${p.column} IS NULL OR ${p.column} = '')`); break\n case 'contains': clauses.push(`${p.column} ILIKE '%' || $${params.push(p.value)} || '%'`); break\n case 'startsWith': clauses.push(`${p.column} ILIKE $${params.push(p.value)} || '%'`); break\n case 'eq': clauses.push(`${p.column} = $${params.push(p.value)}`); break\n case 'gt': clauses.push(`${p.column} > $${params.push(p.value)}`); break\n case 'lt': clauses.push(`${p.column} < $${params.push(p.value)}`); break\n case 'between': clauses.push(`${p.column} BETWEEN $${params.push(p.value)} AND $${params.push(p.valueTo)}`); break\n }\n }\n\n if (search) {\n const p = params.push(search)\n const or = searchable.map((c) => `${c} ILIKE '%' || $${p} || '%'`).join(' OR ')\n clauses.push(`(${or})`)\n }\n\n return { where: clauses.length ? 'WHERE ' + clauses.join(' AND ') : '', params }\n}\n```\n\nEvery value goes through `params.push`; nothing user-supplied is interpolated\ninto the SQL text.\n\n## Wiring the grid to the controller\n\nRun the grid with `externalFilter` so it emits intent instead of filtering\nlocally, and forward the change to `ctl.setFilter`. Debounce the rapid changes -\neach keystroke should not become its own round trip.\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid } from '@svgrid/grid'\n import { createServerDataSource, type ServerFilterModel, type ServerState } from '@svgrid/grid'\n\n let view = $state<ServerState<Row>>()\n const ctl = createServerDataSource(source, {\n pageSize: 50,\n onChange: (s) => (view = s),\n })\n ctl.refresh()\n\n // Debounce so typing in the quick search does not fire a query per keystroke.\n let timer: ReturnType<typeof setTimeout>\n function applyFilter(model: ServerFilterModel) {\n clearTimeout(timer)\n timer = setTimeout(() => ctl.setFilter(model), 250)\n }\n\n // Adapt the grid's filter change into a ServerFilterModel.\n function onFiltersChange(f: { global?: string; columns?: any }) {\n applyFilter({ global: f.global, columns: toColumnModel(f.columns) })\n }\n</script>\n\n{#if view}\n <SvGrid\n data={view.rows}\n {columns} {features}\n filterable\n externalFilter\n loading={view.loading}\n pageable={false}\n {onFiltersChange}\n />\n{/if}\n```\n\n`setFilter` resets to page 0 and re-fetches, so a new filter always shows its\nfirst page of matches. The controller's monotonic request id means a slow\nresponse for an old filter can never land after a newer one.\n\n## Index the columns you filter\n\nThe model pushes filtering to the database, so the database has to be ready for\nit. Add an index on each column you filter or sort by. `contains`\n(`ILIKE '%x%'`) cannot use a plain B-tree index - reach for a trigram\n(`pg_trgm`) index or a full-text column for large tables, and prefer\n`startsWith` or `equals` where the UX allows, since those are index-friendly.\n\n## Set-filter values from the server\n\nA column's filter checklist normally lists the distinct values found in the rows\nthe grid has loaded - but in server mode that is only the current page, so values\nthat live on other pages never appear. Pass `serverFilterValues` and the grid\nfetches the full distinct list from your backend the first time a column's filter\nmenu opens (cached per column):\n\n```svelte\n<SvGrid\n {columns}\n serverFilterValues={async (columnId) => {\n const res = await fetch(`/api/values?column=${columnId}`) // SELECT DISTINCT col ...\n return res.json() // string[]\n }}\n/>\n```\n\nNow the checklist shows every value, not just the ones on screen; selecting them\ndrives `filterModel.columns[col].selectedValues` as usual.\n\n## See also\n\n- [Server-Side Row Model](./server-row-model.md) - the datasource contract and request lifecycle.\n- [Server editing](./server-editing.md) - the write side: create / update / delete with optimistic updates.\n"
3779
+ "markdown": "# Server filtering\n\nWhen the data lives on the server, the grid does not filter rows itself. It\nrecords what the user typed and emits a single **`ServerFilterModel`**, and your\nbackend turns that model into a `WHERE` clause. This page is a deep dive into\nthat model: its exact shape, the operator set, set-filter faceting, the global\nquick search, and how to map all of it to a **parameterized** query with the\n`normalizeFilters` helper from `@svgrid/enterprise`.\n\nIt builds on the [Server-Side Row Model](./server-row-model.md), where\n`createServerDataSource` owns the request lifecycle.\n\n![Filter inputs and a quick-search box collapse into one ServerFilterModel with global and columns, which normalizeFilters turns into parameterized WHERE predicates that fetch the filtered page from the server.](/docs-media/server-filtering.svg)\n\n## The `ServerFilterModel` shape\n\nEvery `getRows(request)` call receives the current filter as\n`request.filterModel`. It has two parts: a `global` quick-search string and a\n`columns` map keyed by column id.\n\n```ts\ntype ServerFilterModel = {\n global?: string // the quick-filter search box\n columns?: Record<string, { // keyed by column id\n operator: string // equals | contains | startsWith | greaterThan | lessThan | between | isBlank\n value: string\n valueTo?: string // second bound, for `between`\n selectedValues?: string[] // set-filter (facet checklist) selection\n }>\n}\n```\n\nA populated model:\n\n```json\n{\n \"global\": \"berlin\",\n \"columns\": {\n \"status\": { \"operator\": \"equals\", \"value\": \"active\" },\n \"age\": { \"operator\": \"between\", \"value\": \"18\", \"valueTo\": \"65\" },\n \"country\": { \"operator\": \"contains\", \"value\": \"\", \"selectedValues\": [\"DE\", \"FR\"] }\n }\n}\n```\n\nEach entry may carry an operator-style filter (`value` plus, for `between`, a\n`valueTo`) **or** a set-filter selection (`selectedValues`), or both. When\n`selectedValues` is present it wins - the checklist selection takes precedence\nover the operator value.\n\n## The operator set\n\n`operator` is one of seven values. Map each to a predicate:\n\n| `operator` | SQL |\n| -------------- | ------------------------------------- |\n| `equals` | `col = $value` |\n| `contains` | `col ILIKE '%' || $value || '%'` |\n| `startsWith` | `col ILIKE $value || '%'` |\n| `greaterThan` | `col > $value` |\n| `lessThan` | `col < $value` |\n| `between` | `col BETWEEN $value AND $valueTo` |\n| `isBlank` | `col IS NULL OR col = ''` |\n\nAny unrecognized operator is treated as `contains` - the safe, permissive\ndefault.\n\n## Set filters and faceting\n\nA set filter (facet checklist) is expressed with `selectedValues`: the list of\nvalues the user ticked. It maps to an `IN (...)` predicate:\n\n```sql\ncol IN ($v0, $v1, $v2) -- one bound parameter per selected value\n```\n\nBecause `selectedValues` takes precedence over `operator` / `value`, a column\nthat has both a checklist selection and a typed value filters by the checklist.\nBuild the facet list itself with a separate `SELECT DISTINCT col` (or a\npre-computed facet count) query - the model carries only the selection, not the\navailable options.\n\n## The global quick filter\n\n`global` is the free-text quick-search box. It is not scoped to one column: it\nis an **`OR` across your searchable columns**. You decide which columns are\nsearchable.\n\n```sql\n-- global = 'berlin'\n(name ILIKE '%' || $q || '%' OR city ILIKE '%' || $q || '%' OR country ILIKE '%' || $q || '%')\n```\n\nCombine the global `OR` group with the per-column predicates using `AND`: a row\nmust match the quick search **and** every active column filter.\n\n## Mapping to a parameterized WHERE\n\nThe one rule that matters: **never string-concatenate user values into SQL.**\nBind every value as a parameter so a value like `'; DROP TABLE ...` is data, not\ncode. The `IN (...)` list gets one placeholder per selected value; `between`\ngets two.\n\nYou do not have to hand-write the operator switch. `@svgrid/enterprise` ships\n`normalizeFilters(model)`, which flattens the model into one uniform list of\npredicates plus the trimmed search term - the same helper the built-in REST and\nSQL sources use.\n\n```ts\nimport { normalizeFilters } from '@svgrid/enterprise'\n\nconst { predicates, search } = normalizeFilters(filterModel)\n// predicates: Array of backend-neutral predicates over one column each -\n// { column, op: 'in', values } // set filter\n// { column, op: 'isNull' } // isBlank\n// { column, op: 'contains' | 'startsWith' | 'eq' | 'gt' | 'lt', value }\n// { column, op: 'between', value, valueTo }\n// search: the trimmed global term (or undefined)\n```\n\n`normalizeFilters` also does the tidying you would otherwise repeat in every\nbackend: it drops empty operator filters, trims values, prefers `selectedValues`\nwhen present, and for `between` fills a missing bound from the other. Turning\nthat neutral list into bound SQL is then a small, safe switch:\n\n```ts\nfunction buildWhere(filterModel, searchable) {\n const { predicates, search } = normalizeFilters(filterModel)\n const clauses = []\n const params = []\n\n for (const p of predicates) {\n switch (p.op) {\n case 'in': {\n // one bound placeholder per selected value\n const start = params.length\n p.values.forEach((v) => params.push(v))\n const list = p.values.map((_, i) => `$${start + i + 1}`).join(', ')\n clauses.push(`${p.column} IN (${list})`)\n break\n }\n case 'isNull': clauses.push(`(${p.column} IS NULL OR ${p.column} = '')`); break\n case 'contains': clauses.push(`${p.column} ILIKE '%' || $${params.push(p.value)} || '%'`); break\n case 'startsWith': clauses.push(`${p.column} ILIKE $${params.push(p.value)} || '%'`); break\n case 'eq': clauses.push(`${p.column} = $${params.push(p.value)}`); break\n case 'gt': clauses.push(`${p.column} > $${params.push(p.value)}`); break\n case 'lt': clauses.push(`${p.column} < $${params.push(p.value)}`); break\n case 'between': clauses.push(`${p.column} BETWEEN $${params.push(p.value)} AND $${params.push(p.valueTo)}`); break\n }\n }\n\n if (search) {\n const p = params.push(search)\n const or = searchable.map((c) => `${c} ILIKE '%' || $${p} || '%'`).join(' OR ')\n clauses.push(`(${or})`)\n }\n\n return { where: clauses.length ? 'WHERE ' + clauses.join(' AND ') : '', params }\n}\n```\n\nEvery value goes through `params.push`; nothing user-supplied is interpolated\ninto the SQL text.\n\n## Wiring the grid to the controller\n\nRun the grid with `externalFilter` so it emits intent instead of filtering\nlocally, and forward the change to `ctl.setFilter`. Debounce the rapid changes -\neach keystroke should not become its own round trip.\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid } from '@svgrid/grid'\n import { createServerDataSource, type ServerFilterModel, type ServerState } from '@svgrid/grid'\n\n let view = $state<ServerState<Row>>()\n const ctl = createServerDataSource(source, {\n pageSize: 50,\n onChange: (s) => (view = s),\n })\n ctl.refresh()\n\n // Debounce so typing in the quick search does not fire a query per keystroke.\n let timer: ReturnType<typeof setTimeout>\n function applyFilter(model: ServerFilterModel) {\n clearTimeout(timer)\n timer = setTimeout(() => ctl.setFilter(model), 250)\n }\n\n // Adapt the grid's filter change into a ServerFilterModel.\n function onFiltersChange(f: { global?: string; columns?: any }) {\n applyFilter({ global: f.global, columns: toColumnModel(f.columns) })\n }\n</script>\n\n{#if view}\n <SvGrid\n data={view.rows}\n {columns} {features}\n filterable\n externalFilter\n loading={view.loading}\n pageable={false}\n {onFiltersChange}\n />\n{/if}\n```\n\n`setFilter` resets to page 0 and re-fetches, so a new filter always shows its\nfirst page of matches. The controller's monotonic request id means a slow\nresponse for an old filter can never land after a newer one.\n\n## Index the columns you filter\n\nThe model pushes filtering to the database, so the database has to be ready for\nit. Add an index on each column you filter or sort by. `contains`\n(`ILIKE '%x%'`) cannot use a plain B-tree index - reach for a trigram\n(`pg_trgm`) index or a full-text column for large tables, and prefer\n`startsWith` or `equals` where the UX allows, since those are index-friendly.\n\n## Set-filter values from the server\n\nA column's filter checklist normally lists the distinct values found in the rows\nthe grid has loaded - but in server mode that is only the current page, so values\nthat live on other pages never appear. Pass `serverFilterValues` and the grid\nfetches the full distinct list from your backend the first time a column's filter\nmenu opens (cached per column):\n\n```svelte\n<SvGrid\n {columns}\n serverFilterValues={async (columnId) => {\n const res = await fetch(`/api/values?column=${columnId}`) // SELECT DISTINCT col ...\n return res.json() // string[]\n }}\n/>\n```\n\nNow the checklist shows every value, not just the ones on screen; selecting them\ndrives `filterModel.columns[col].selectedValues` as usual.\n\n## The advanced filter over the wire\n\n`filterModel.columns` is a flat map with an implicit AND, so it cannot express\nOR across columns, nesting, negation, two conditions on one column, or a\ncomparison against an aggregate. Those arrive separately, as a JSON expression:\n\n```ts\nfilterModel.expression // GridPredicateExpr | undefined\n```\n\n### It is all or nothing\n\nA backend that receives an expression MUST either translate **the whole thing**,\nmake `rowCount` reflect it, and acknowledge it:\n\n```ts\nreturn { rows, rowCount, appliedExpression: true }\n```\n\nor apply **none** of it and stay silent:\n\n```ts\nreturn { rows, rowCount } // no acknowledgement\n```\n\nPartial application is a contract violation rather than a degraded mode.\nDropping a clause makes the result *broader*, so the grid would show rows the\nuser's filter excluded, while the UI says the filter is on. Nothing about that\nresult looks wrong, which is what makes it dangerous.\n\n### What happens when you do not apply it\n\nThe grid does **not** filter the loaded page for you. Filtering one page would\nturn \"3 of 1,000,000 match\" into a confident lie and make paging incoherent,\nsince page 2 would re-filter a different slice. Instead the controller sets\n`state.expressionUnapplied`, logs one warning, and leaves the rows alone, so you\ncan show the user that the filter did not run:\n\n```svelte\n{#if state.expressionUnapplied}\n <p role=\"status\">\n This grid loads rows from a server that has not applied the advanced\n filter, so the results below are unfiltered.\n </p>\n{/if}\n```\n\n### Using the plan seam\n\n`planQuery` admits the expression only when every column it references is on the\n`EntitySchema` - and rejects it **whole** if any is not, for the reason above.\n`createInMemoryDataSource` implements the contract end to end and is the\nreference to test a real backend against.\n\n```ts\nconst plan = planQuery(schema, request)\nif (plan.expression) {\n // Safe to translate: every column is on the schema.\n // Translate it in full, or do not acknowledge it.\n}\n```\n\n## See also\n\n- [Server-Side Row Model](./server-row-model.md) - the datasource contract and request lifecycle.\n- [Server editing](./server-editing.md) - the write side: create / update / delete with optimistic updates.\n"
3762
3780
  },
3763
3781
  {
3764
3782
  "slug": "help/server/server-grouping",
3765
3783
  "path": "docs/help/server/server-grouping.md",
3766
3784
  "title": "Server grouping",
3767
- "markdown": "# Server grouping\n\nGrouping a hundred thousand rows in the browser means shipping all hundred\nthousand rows first. Server grouping flips that: the backend runs the\n`GROUP BY`, and the grid receives **one pre-aggregated row per group** - a key\nplus its subtotals. Expanding a group then lazily drills into the next level (or\nthe raw rows) for **that group only**, so the network never carries the full\ntable.\n\nThis is **first-class in SvGrid**: grouping flows through the **same\n`ServerDataSource.getRows` contract** as paging, sorting, and filtering.\nThe request carries `groupBy` (the columns grouped on) and `groupKeys` (the path\nof the group being expanded); `createServerGroupModel` owns the group tree -\nlazy fetch per level, caching, race-safety, expand/collapse - and hands you a\nflat list of display rows to render.\n\n<img src=\"/docs-media/server-grouping.svg\" alt=\"Server grouping flow: the backend runs GROUP BY and returns one pre-aggregated row per group instead of the raw table; the grid renders those group rows; expanding one group drills into the next level for that group only.\" width=\"100%\" />\n\n<div data-docs-demo=\"344-server-grouping-model\" data-height=\"480\"></div>\n\n## The contract\n\nOne `getRows`. When `groupKeys.length < groupBy.length` the server returns\n**group rows** (one per distinct key at that level, carrying the group key and\nits aggregates); when they are equal it returns the **leaf rows** under that\npath.\n\n```ts\nasync function getRows(req) {\n const level = req.groupKeys.length\n if (level < req.groupBy.length) {\n // GROUP row level: GROUP BY the column at this level, within groupKeys.\n const col = req.groupBy[level] // e.g. 'country', then 'city'\n // SELECT country AS key, SUM(amount) amount, COUNT(*) n\n // FROM sales WHERE <groupKeys path> GROUP BY country\n return { rows: groupRows, rowCount: groupRows.length }\n }\n // LEAF level: the raw rows under the fully-specified path.\n // SELECT * FROM sales WHERE country = $1 AND city = $2 LIMIT ...\n return { rows: leafRows, rowCount: total }\n}\n```\n\nEach group row is a plain object carrying the group column's value (under that\ncolumn's field) and the aggregate values (under each aggregation column) - the\ncontroller reads them straight off the row.\n\n![The display-row pipeline: getRows with groupBy and groupKeys feeds the controller's cached group tree, flatten produces one displayRows list, and each row is one of five kinds - group, leaf, Load more, Total, or skeleton - that SvGroupCell renders.](/docs-media/server-group-pipeline.svg)\n\n## Wiring the model\n\n```ts\nimport { createServerGroupModel, type ServerGroupState } from '@svgrid/grid'\n\nlet view = $state<ServerGroupState<Sale>>()\nconst ctl = createServerGroupModel<Sale>(source, {\n groupBy: ['country', 'city'], // group two levels deep\n aggregations: [{ col: 'amount', fn: 'sum' }], // roll up per group\n onChange: (s) => (view = s),\n})\nctl.refresh() // load the top level\n```\n\n`view.displayRows` is the flattened tree: top-level groups, with each expanded\ngroup's children spliced in beneath it. Every group row carries `level` (for\nindentation), `expanded`, `loading`, `key`, and `aggregates`.\n\n## Rendering the display rows\n\nThree built-ins do the work, so you write **no cell markup**. `serverGroupRows`\nmaps the display rows to grid rows (spreading each row's data, so a value column\nshows the subtotal on a group row and the cell value on a leaf); the shipped\n`SvGroupCell` draws the expander + indentation; and `serverGroupNav(ctl)` is one\nhandler that drives both the cell clicks and the grid's keyboard:\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid, serverGroupRows, serverGroupNav, SvGroupCell, renderComponent } from '@svgrid/grid'\n\n const nav = serverGroupNav(ctl)\n const rows = $derived(serverGroupRows(view))\n const columns = [\n { field: 'country', header: 'Group', width: 280,\n cell: (ctx) => renderComponent(SvGroupCell, {\n row: ctx.row.original, onToggle: nav.onToggle, leafField: 'name',\n }) },\n { field: 'amount', header: 'Amount', align: 'right',\n format: { type: 'number', options: { style: 'currency', currency: 'USD' } } },\n ]\n</script>\n\n<SvGrid data={rows} {columns} serverGroup={nav} />\n```\n\n`SvGroupCell` renders the group key with an expander (indented by depth) for group\nrows and the `leafField` value for leaves. Want full control? Every grid row\ncarries a `__group` marker (the `ServerDisplayRow`), so you can skip `SvGroupCell`\nand render your own cell from it.\n\n## Keyboard and accessibility\n\n`serverGroup={nav}` makes the grid handle tree navigation itself - built in, no\napp key handling:\n\n- **ArrowRight** expands the focused group row; **ArrowLeft** collapses it.\n- The grid takes the `treegrid` role and sets `aria-level` + `aria-expanded` on\n each row, so screen readers announce the depth and expanded state.\n\nIt works for [tree mode](./server-tree-data.md) the same way.\n\n## Load more within a group\n\nBy default the controller fetches up to `pageSize` (200) children per group in one\ncall. When a group has more, `serverGroupRows` emits a **load more** row at the\nend of its loaded children, which `SvGroupCell` renders as a \"Load N more\" button;\nclicking it (or calling `ctl.loadMoreChildren(path)`) appends the next block. Set\n`pageSize` to control the block size:\n\n```ts\ncreateServerGroupModel(source, { groupBy: ['country'], pageSize: 50, onChange })\n```\n\nWhile a group's first block is loading, `serverGroupRows` emits placeholder\n**skeleton** rows (count via `skeletonRows`, default 3) that `SvGroupCell`\nrenders as a shimmer, so an expand never shows an empty gap.\n\n## Subtotal footers\n\nTurn on `groupFooters` and each expanded group gets a **Total** row after its\nchildren, carrying the group's aggregates again so the value columns show the\nsubtotal under the detail:\n\n```ts\ncreateServerGroupModel(source, { groupBy: ['region', 'country'], aggregations, groupFooters: true, onChange })\n```\n\n## A row-group panel (drag to group)\n\n`SvRowGroupPanel` is a \"group by\" bar: it shows the current group columns as\nchips you can remove or drag to reorder, plus a menu to add one, and it accepts a\ncolumn drop (`text/sv-column`). Wire its `onChange` to `setGroupBy`:\n\n```svelte\n<script lang=\"ts\">\n import { SvRowGroupPanel } from '@svgrid/grid'\n const groupCols = [{ id: 'region', label: 'Region' }, { id: 'country', label: 'Country' }]\n</script>\n\n<SvRowGroupPanel columns={groupCols} groupBy={view.groupBy} onChange={(g) => ctl.setGroupBy(g)} />\n```\n\n## Multi-level grouping is automatic\n\nSet `groupBy: ['region', 'industry', 'quarter']` and the controller fetches each\nlevel on demand: the top level returns regions, expanding a region fetches its\nindustries, expanding an industry fetches its quarters, and expanding a quarter\nreturns the raw rows. You never configure the levels - each expand is just\nanother `getRows` with a longer `groupKeys`. Change the grouping at runtime with\n`ctl.setGroupBy([...])`; sorting and filtering re-fetch the visible tree via\n`ctl.setSort` / `ctl.setFilter`.\n\n## The win\n\nFor a 100,000-row sales table grouped by three dimensions, the top level returns\na handful of group rows instead of 100,000 raw rows. The client groups nothing\nand holds almost nothing. Grouping 100k rows in JS runs in hundreds of\nmilliseconds; asking the server for the pre-grouped result returns a few rows in\ntens of milliseconds, and the payload shrinks by orders of magnitude.\n\n## Without the controller (manual pattern)\n\nIf your backend or UI needs something bespoke, you can still assemble grouping by\nhand: fetch pre-grouped rows, render them as ordinary rows, and expand each into\na second `<SvGrid data={detailRows}>` or an [expandable detail row](../rows/master-detail.md)\n(`isDetailRow` + `renderDetailRow`), keeping the expanded group id and its lazily\nfetched detail rows in your own state. The [tree toggle pattern](../rows/tree-rows.md)\nis a third option for a single flat, indented list. `createServerGroupModel` is\nthe batteries-included version of exactly this.\n\n<div data-docs-demo=\"114-server-grouping\" data-height=\"480\"></div>\n\n## See also\n\n- [Server-Side Row Model](./server-row-model.md) - the datasource contract that grouping, paging, sort, and filter all share.\n- [Server tree data](./server-tree-data.md) - load-on-demand hierarchies (self-referential trees).\n- [Tree data](../rows/tree-rows.md) - the client-side flat-list + toggle pattern.\n"
3785
+ "markdown": "# Server grouping\n\nGrouping a hundred thousand rows in the browser means shipping all hundred\nthousand rows first. Server grouping flips that: the backend runs the\n`GROUP BY`, and the grid receives **one pre-aggregated row per group** - a key\nplus its subtotals. Expanding a group then lazily drills into the next level (or\nthe raw rows) for **that group only**, so the network never carries the full\ntable.\n\nThis is **first-class in SvGrid**: grouping flows through the **same\n`ServerDataSource.getRows` contract** as paging, sorting, and filtering.\nThe request carries `groupBy` (the columns grouped on) and `groupKeys` (the path\nof the group being expanded); `createServerGroupModel` owns the group tree -\nlazy fetch per level, caching, race-safety, expand/collapse - and hands you a\nflat list of display rows to render.\n\n<img src=\"/docs-media/server-grouping.svg\" alt=\"Server grouping flow: the backend runs GROUP BY and returns one pre-aggregated row per group instead of the raw table; the grid renders those group rows; expanding one group drills into the next level for that group only.\" width=\"100%\" />\n\n<div data-docs-demo=\"344-server-grouping-model\" data-height=\"480\"></div>\n\n## The contract\n\nOne `getRows`. When `groupKeys.length < groupBy.length` the server returns\n**group rows** (one per distinct key at that level, carrying the group key and\nits aggregates); when they are equal it returns the **leaf rows** under that\npath.\n\n```ts\nasync function getRows(req) {\n const level = req.groupKeys.length\n if (level < req.groupBy.length) {\n // GROUP row level: GROUP BY the column at this level, within groupKeys.\n const col = req.groupBy[level] // e.g. 'country', then 'city'\n // SELECT country AS key, SUM(amount) amount, COUNT(*) n\n // FROM sales WHERE <groupKeys path> GROUP BY country\n return { rows: groupRows, rowCount: groupRows.length }\n }\n // LEAF level: the raw rows under the fully-specified path.\n // SELECT * FROM sales WHERE country = $1 AND city = $2 LIMIT ...\n return { rows: leafRows, rowCount: total }\n}\n```\n\n## You probably do not have to write that\n\nMapping the grid's request onto a backend query is the part teams actually\nfind hard, so `@svgrid/enterprise` ships adapters that already do it - SQL,\nREST and Supabase - including the grouped case above.\n\n```ts\nimport { planQuery, planToSql } from '@svgrid/enterprise'\n\n// In your endpoint. `schema` is the EntitySchema for the table.\nconst plan = planQuery(schema, request)\nconst sql = planToSql(plan, { placeholders: '$', ilike: true }) // Postgres\n\nconst rows = await db.query(\n plan.groupBy\n // Grouped level: the plan hands you the SELECT list and GROUP BY.\n ? `SELECT ${sql.select} FROM sales ${sql.whereText} ${sql.groupByText}\n ${sql.orderByText} LIMIT ${sql.limit} OFFSET ${sql.offset}`\n // Leaf level: your own columns.\n : `SELECT * FROM sales ${sql.whereText}\n ${sql.orderByText} LIMIT ${sql.limit} OFFSET ${sql.offset}`,\n sql.params,\n)\n```\n\nThree details the plan handles that are easy to get wrong by hand:\n\n- **The path becomes ordinary predicates.** `groupKeys` arrives as equality\n filters in `plan.where`, so your backend only ever handles \"filter, then\n group by one column\" - never a multi-level GROUP BY.\n- **The count means different things at different levels.** `sql.countText` is\n `COUNT(DISTINCT col)` when grouping and `COUNT(*)` when not, because the grid\n sizes its scrollbar from the number of *groups* at a group level.\n- **Aggregates are aliased back to their source column.** `SUM(\"amount\") AS\n \"amount\"`, because that is the key the grid reads from the group row.\n\nOnly fields declared on the `EntitySchema` reach the plan, so a client cannot\ngroup by or aggregate an identifier you did not declare.\n\nFor REST, `createRestDataSource` sends `?groupBy=region&aggregate=sum:amount`\nplus the path as ordinary filter params. For Postgres via PostgREST,\n`createSupabaseDataSource` uses aggregate selects (requires PostgREST 12+ with\naggregates enabled). `createInMemoryDataSource` implements the whole contract\nin memory and is the reference to test your own backend against.\n\nEach group row is a plain object carrying the group column's value (under that\ncolumn's field) and the aggregate values (under each aggregation column) - the\ncontroller reads them straight off the row.\n\n![The display-row pipeline: getRows with groupBy and groupKeys feeds the controller's cached group tree, flatten produces one displayRows list, and each row is one of five kinds - group, leaf, Load more, Total, or skeleton - that SvGroupCell renders.](/docs-media/server-group-pipeline.svg)\n\n## Wiring the model\n\n```ts\nimport { createServerGroupModel, type ServerGroupState } from '@svgrid/grid'\n\nlet view = $state<ServerGroupState<Sale>>()\nconst ctl = createServerGroupModel<Sale>(source, {\n groupBy: ['country', 'city'], // group two levels deep\n aggregations: [{ col: 'amount', fn: 'sum' }], // roll up per group\n onChange: (s) => (view = s),\n})\nctl.refresh() // load the top level\n```\n\n`view.displayRows` is the flattened tree: top-level groups, with each expanded\ngroup's children spliced in beneath it. Every group row carries `level` (for\nindentation), `expanded`, `loading`, `key`, and `aggregates`.\n\n## Rendering the display rows\n\nThree built-ins do the work, so you write **no cell markup**. `serverGroupRows`\nmaps the display rows to grid rows (spreading each row's data, so a value column\nshows the subtotal on a group row and the cell value on a leaf); the shipped\n`SvGroupCell` draws the expander + indentation; and `serverGroupNav(ctl)` is one\nhandler that drives both the cell clicks and the grid's keyboard:\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid, serverGroupRows, serverGroupNav, SvGroupCell, renderComponent } from '@svgrid/grid'\n\n const nav = serverGroupNav(ctl)\n const rows = $derived(serverGroupRows(view))\n const columns = [\n { field: 'country', header: 'Group', width: 280,\n cell: (ctx) => renderComponent(SvGroupCell, {\n row: ctx.row.original, onToggle: nav.onToggle, leafField: 'name',\n }) },\n { field: 'amount', header: 'Amount', align: 'right',\n format: { type: 'number', options: { style: 'currency', currency: 'USD' } } },\n ]\n</script>\n\n<SvGrid data={rows} {columns} serverGroup={nav} />\n```\n\n`SvGroupCell` renders the group key with an expander (indented by depth) for group\nrows and the `leafField` value for leaves. Want full control? Every grid row\ncarries a `__group` marker (the `ServerDisplayRow`), so you can skip `SvGroupCell`\nand render your own cell from it.\n\n## Keyboard and accessibility\n\n`serverGroup={nav}` makes the grid handle tree navigation itself - built in, no\napp key handling:\n\n- **ArrowRight** expands the focused group row; **ArrowLeft** collapses it.\n- The grid takes the `treegrid` role and sets `aria-level` + `aria-expanded` on\n each row, so screen readers announce the depth and expanded state.\n\nIt works for [tree mode](./server-tree-data.md) the same way.\n\n## Load more within a group\n\nBy default the controller fetches up to `pageSize` (200) children per group in one\ncall. When a group has more, `serverGroupRows` emits a **load more** row at the\nend of its loaded children, which `SvGroupCell` renders as a \"Load N more\" button;\nclicking it (or calling `ctl.loadMoreChildren(path)`) appends the next block. Set\n`pageSize` to control the block size:\n\n```ts\ncreateServerGroupModel(source, { groupBy: ['country'], pageSize: 50, onChange })\n```\n\nWhile a group's first block is loading, `serverGroupRows` emits placeholder\n**skeleton** rows (count via `skeletonRows`, default 3) that `SvGroupCell`\nrenders as a shimmer, so an expand never shows an empty gap.\n\n## Subtotal footers\n\nTurn on `groupFooters` and each expanded group gets a **Total** row after its\nchildren, carrying the group's aggregates again so the value columns show the\nsubtotal under the detail:\n\n```ts\ncreateServerGroupModel(source, { groupBy: ['region', 'country'], aggregations, groupFooters: true, onChange })\n```\n\n## A row-group panel (drag to group)\n\n`SvRowGroupPanel` is a \"group by\" bar: it shows the current group columns as\nchips you can remove or drag to reorder, plus a menu to add one, and it accepts a\ncolumn drop (`text/sv-column`). Wire its `onChange` to `setGroupBy`:\n\n```svelte\n<script lang=\"ts\">\n import { SvRowGroupPanel } from '@svgrid/grid'\n const groupCols = [{ id: 'region', label: 'Region' }, { id: 'country', label: 'Country' }]\n</script>\n\n<SvRowGroupPanel columns={groupCols} groupBy={view.groupBy} onChange={(g) => ctl.setGroupBy(g)} />\n```\n\n## Multi-level grouping is automatic\n\nSet `groupBy: ['region', 'industry', 'quarter']` and the controller fetches each\nlevel on demand: the top level returns regions, expanding a region fetches its\nindustries, expanding an industry fetches its quarters, and expanding a quarter\nreturns the raw rows. You never configure the levels - each expand is just\nanother `getRows` with a longer `groupKeys`. Change the grouping at runtime with\n`ctl.setGroupBy([...])`; sorting and filtering re-fetch the visible tree via\n`ctl.setSort` / `ctl.setFilter`.\n\n## The win\n\nFor a 100,000-row sales table grouped by three dimensions, the top level returns\na handful of group rows instead of 100,000 raw rows. The client groups nothing\nand holds almost nothing. Grouping 100k rows in JS runs in hundreds of\nmilliseconds; asking the server for the pre-grouped result returns a few rows in\ntens of milliseconds, and the payload shrinks by orders of magnitude.\n\n## Without the controller (manual pattern)\n\nIf your backend or UI needs something bespoke, you can still assemble grouping by\nhand: fetch pre-grouped rows, render them as ordinary rows, and expand each into\na second `<SvGrid data={detailRows}>` or an [expandable detail row](../rows/master-detail.md)\n(`isDetailRow` + `renderDetailRow`), keeping the expanded group id and its lazily\nfetched detail rows in your own state. The [tree toggle pattern](../rows/tree-rows.md)\nis a third option for a single flat, indented list. `createServerGroupModel` is\nthe batteries-included version of exactly this.\n\n<div data-docs-demo=\"114-server-grouping\" data-height=\"480\"></div>\n\n## See also\n\n- [Server-Side Row Model](./server-row-model.md) - the datasource contract that grouping, paging, sort, and filter all share.\n- [Server tree data](./server-tree-data.md) - load-on-demand hierarchies (self-referential trees).\n- [Tree data](../rows/tree-rows.md) - the client-side flat-list + toggle pattern.\n"
3768
3786
  },
3769
3787
  {
3770
3788
  "slug": "help/server/server-paging",
@@ -3794,7 +3812,7 @@ export const docs = [
3794
3812
  "slug": "help/shadcn",
3795
3813
  "path": "docs/help/shadcn.md",
3796
3814
  "title": "shadcn-svelte integration",
3797
- "markdown": "# shadcn-svelte integration\r\n\r\nIf your Svelte 5 app already uses shadcn-svelte, SvGrid drops in\r\nwithout a redesign. You keep the exact palette, radius, font, and dark\r\nmode you already have - the grid reads them straight from the same CSS\r\nvariables your `Button`, `Input`, and `Table` components use.\r\n\r\nThere is nothing to \"port\". A shadcn app stores its theme as a flat\r\nset of CSS custom properties (`--background`, `--foreground`,\r\n`--border`, `--primary`, ...). SvGrid themes off its own flat set of\r\ncustom properties (`--sg-*`). Wiring the two together is one CSS block\r\n- no JavaScript, no theme provider, no re-render on toggle.\r\n\r\n## Side by side: the same table, before and after\r\n\r\nOn the left is what a shadcn app already has - a hand-written\r\n`Table`. On the right is the same region rendered by `<SvGrid>`, which\r\ninherits the identical tokens and adds sorting, filtering, and\r\nvirtualization for free. The only new lines are the import and the\r\ngrid element.\r\n\r\n<div style=\"display:grid;gap:1rem;grid-template-columns:1fr 1fr;align-items:start;margin:1rem 0\">\r\n <div style=\"min-width:0\">\r\n <strong>Before - shadcn <code>Table</code></strong>\r\n <pre style=\"overflow:auto\"><code>&lt;script lang=\"ts\"&gt;\r\n import * as Table from '$lib/components/ui/table'\r\n let rows = [/* ...orders... */]\r\n&lt;/script&gt;\r\n\r\n&lt;Table.Root&gt;\r\n &lt;Table.Header&gt;\r\n &lt;Table.Row&gt;\r\n &lt;Table.Head&gt;Order&lt;/Table.Head&gt;\r\n &lt;Table.Head&gt;Customer&lt;/Table.Head&gt;\r\n &lt;Table.Head class=\"text-right\"&gt;Amount&lt;/Table.Head&gt;\r\n &lt;/Table.Row&gt;\r\n &lt;/Table.Header&gt;\r\n &lt;Table.Body&gt;\r\n {#each rows as r}\r\n &lt;Table.Row&gt;\r\n &lt;Table.Cell&gt;{r.id}&lt;/Table.Cell&gt;\r\n &lt;Table.Cell&gt;{r.customer}&lt;/Table.Cell&gt;\r\n &lt;Table.Cell class=\"text-right\"&gt;{r.amount}&lt;/Table.Cell&gt;\r\n &lt;/Table.Row&gt;\r\n {/each}\r\n &lt;/Table.Body&gt;\r\n&lt;/Table.Root&gt;\r\n&lt;!-- static markup: no sort, no filter, no virtualization --&gt;</code></pre>\r\n </div>\r\n <div style=\"min-width:0\">\r\n <strong>After - <code>&lt;SvGrid&gt;</code>, same tokens</strong>\r\n <pre style=\"overflow:auto\"><code>&lt;script lang=\"ts\"&gt;\r\n import { SvGrid, type ColumnDef } from '@svgrid/grid'\r\n import '@svgrid/grid/themes/shadcn.css' // 1 line to match shadcn\r\n\r\n let rows = [/* ...same orders... */]\r\n const columns: ColumnDef[] = [\r\n { id: 'id', field: 'id', header: 'Order' },\r\n { id: 'customer', field: 'customer', header: 'Customer' },\r\n { id: 'amount', field: 'amount', header: 'Amount', type: 'number' },\r\n ]\r\n&lt;/script&gt;\r\n\r\n&lt;SvGrid\r\n data={rows}\r\n {columns}\r\n sortable\r\n filterable\r\n class=\"rounded-md border\" /&gt;\r\n&lt;!-- sortable, filterable, virtualized, keyboard + a11y --&gt;</code></pre>\r\n </div>\r\n</div>\r\n\r\nThe `@svgrid/grid/themes/shadcn.css` import above is the fastest path:\r\na ready-made preset that mirrors shadcn's zinc-neutral palette in light\r\nand dark, toggled by the same `[data-theme='dark']` attribute you\r\nalready use. Import it once and you are done.\r\n\r\n## Inherit your *exact* theme with the live token bridge\r\n\r\nThe preset matches the default shadcn look. If you have customized your\r\ntheme - a different `--primary`, a wider `--radius`, a brand font - skip\r\nthe preset and bridge SvGrid's tokens to *your* variables instead. Now\r\nthe grid tracks whatever your app's tokens resolve to, in real time.\r\n\r\nshadcn stores its palette as bare HSL channels (`--background` holds\r\n`0 0% 100%`, not `hsl(0 0% 100%)`), so wrap each one in `hsl()` at the\r\npoint of assignment:\r\n\r\n```css\r\n/* Scope to a wrapper so only grids inside it inherit the map.\r\n * Move it to :root in app.css to theme every grid on the site. */\r\n.sg-shadcn {\r\n --sg-bg: hsl(var(--background));\r\n --sg-fg: hsl(var(--foreground));\r\n --sg-border: hsl(var(--border));\r\n --sg-header-bg: hsl(var(--muted));\r\n --sg-header-fg: hsl(var(--foreground));\r\n --sg-row-alt-bg: hsl(var(--muted) / 0.3);\r\n --sg-row-hover-bg: hsl(var(--accent));\r\n --sg-selection-bg: hsl(var(--primary) / 0.15);\r\n --sg-accent: hsl(var(--primary));\r\n --sg-focus-ring: 0 0 0 2px hsl(var(--ring));\r\n --sg-scrollbar-bg: hsl(var(--background));\r\n --sg-scrollbar-thumb: hsl(var(--muted-foreground) / 0.4);\r\n --sg-scrollbar-thumb-hover: hsl(var(--muted-foreground) / 0.6);\r\n --sg-font: var(--font-sans, sans-serif);\r\n --sg-radius: var(--radius);\r\n --sg-row-height: 40px;\r\n}\r\n```\r\n\r\n```svelte\r\n<div class=\"sg-shadcn\">\r\n <SvGrid {data} {columns} sortable filterable />\r\n</div>\r\n```\r\n\r\n**Always wrap in `hsl()`.** Writing `--sg-accent: var(--primary)`\r\npasses bare HSL channels straight to the grid, which expects a complete\r\ncolour value - the result is an invalid colour (usually transparent).\r\n\r\n## Dark mode is automatic\r\n\r\nshadcn already redefines its tokens under `.dark`. Because every\r\n`--sg-*` value above is expressed *in terms of* those tokens, the grid\r\nflips with the rest of your app the instant `.dark` is toggled on\r\n`<html>` - no listener, no reactive statement, no API call. SvGrid\r\nreads `--sg-*` from the DOM at paint time, so mid-session theme\r\nswitches repaint the grid for free.\r\n\r\nThis also means nested themes work: put `.dark` on one panel and grids\r\ninside it go dark while the rest of the page stays light.\r\n\r\n## Custom cells with shadcn components\r\n\r\nCell snippets are plain Svelte, so you can render shadcn components\r\ndirectly inside the grid - a `Badge` for status, a `Button` for row\r\nactions:\r\n\r\n```svelte\r\n{#snippet StatusCell({ value }: { value: string })}\r\n <Badge variant={value === 'delivered' ? 'default' : 'secondary'}>\r\n {value}\r\n </Badge>\r\n{/snippet}\r\n\r\n{#snippet RowActions({ row })}\r\n <Button variant=\"ghost\" size=\"sm\" onclick={() => edit(row)}>Edit</Button>\r\n{/snippet}\r\n```\r\n\r\nAssign the snippet to a column's `cell` field\r\n(`{ id: 'status', field: 'status', header: 'Status', cell: StatusCell }`)\r\nand it renders in every row, inheriting the same theme context.\r\n\r\n## A note on OKLCH\r\n\r\nRecent shadcn-svelte releases have been migrating tokens from bare HSL\r\nchannels to OKLCH. If your `app.css` defines `--primary` as\r\n`oklch(...)`, use `oklch(var(--primary) / 0.15)` in the bridge instead\r\nof `hsl(...)`. Check the format of `--background` in your `app.css`\r\nbefore writing the map; open DevTools and confirm the computed value of\r\n`--sg-bg` is a real colour, not transparent, after any shadcn upgrade.\r\n\r\n## Frequently asked questions\r\n\r\n### Does SvGrid work with shadcn-svelte?\r\n\r\nYes. Import the `@svgrid/grid/themes/shadcn.css` preset for the default\r\nshadcn look, or bridge SvGrid's `--sg-*` tokens to your own\r\n`--background` / `--foreground` / `--primary` variables to inherit your\r\nexact customized theme. Both handle dark mode through the same\r\n`.dark` / `[data-theme='dark']` toggle you already use.\r\n\r\n### Do I need to duplicate my theme for the grid?\r\n\r\nNo. The whole point of the token bridge is that there is one source of\r\ntruth - your shadcn variables. The grid reads them; it does not copy\r\nthem.\r\n\r\n### Will dark mode break?\r\n\r\nNo, as long as your bridge expresses `--sg-*` in terms of shadcn tokens\r\n(`hsl(var(--background))`) rather than hard-coded colours. The cascade\r\nhandles the flip.\r\n\r\n## See also\r\n\r\n- [Design tokens](./tokens.md) - the full `--sg-*` surface and the 19\r\n built-in presets (`shadcn`, `tailwind`, `material`, ...)\r\n- [Tailwind integration](./tailwind.md) - wiring tokens through\r\n Tailwind's `theme(...)` layer\r\n- [Theme integrations](https://svgrid.com/demos/74-theme-integrations/)\r\n demo - shadcn and four other design systems, side by side, light and\r\n dark\r\n"
3815
+ "markdown": "# shadcn-svelte integration\r\n\r\nIf your Svelte 5 app already uses shadcn-svelte, SvGrid drops in\r\nwithout a redesign. You keep the exact palette, radius, font, and dark\r\nmode you already have - the grid reads them straight from the same CSS\r\nvariables your `Button`, `Input`, and `Table` components use.\r\n\r\nThere is nothing to \"port\". A shadcn app stores its theme as a flat\r\nset of CSS custom properties (`--background`, `--foreground`,\r\n`--border`, `--primary`, ...). SvGrid themes off its own flat set of\r\ncustom properties (`--sg-*`). Wiring the two together is one CSS block\r\n- no JavaScript, no theme provider, no re-render on toggle.\r\n\r\n## Side by side: the same table, before and after\r\n\r\nOn the left is what a shadcn app already has - a hand-written\r\n`Table`. On the right is the same region rendered by `<SvGrid>`, which\r\ninherits the identical tokens and adds sorting, filtering, and\r\nvirtualization for free. The only new lines are the import and the\r\ngrid element.\r\n\r\n<div style=\"display:grid;gap:1rem;grid-template-columns:1fr 1fr;align-items:start;margin:1rem 0\">\r\n <div style=\"min-width:0\">\r\n <strong>Before - shadcn <code>Table</code></strong>\r\n <pre style=\"overflow:auto\"><code>&lt;script lang=\"ts\"&gt;\r\n import * as Table from '$lib/components/ui/table'\r\n let rows = [/* ...orders... */]\r\n&lt;/script&gt;\r\n\r\n&lt;Table.Root&gt;\r\n &lt;Table.Header&gt;\r\n &lt;Table.Row&gt;\r\n &lt;Table.Head&gt;Order&lt;/Table.Head&gt;\r\n &lt;Table.Head&gt;Customer&lt;/Table.Head&gt;\r\n &lt;Table.Head class=\"text-right\"&gt;Amount&lt;/Table.Head&gt;\r\n &lt;/Table.Row&gt;\r\n &lt;/Table.Header&gt;\r\n &lt;Table.Body&gt;\r\n {#each rows as r}\r\n &lt;Table.Row&gt;\r\n &lt;Table.Cell&gt;{r.id}&lt;/Table.Cell&gt;\r\n &lt;Table.Cell&gt;{r.customer}&lt;/Table.Cell&gt;\r\n &lt;Table.Cell class=\"text-right\"&gt;{r.amount}&lt;/Table.Cell&gt;\r\n &lt;/Table.Row&gt;\r\n {/each}\r\n &lt;/Table.Body&gt;\r\n&lt;/Table.Root&gt;\r\n&lt;!-- static markup: no sort, no filter, no virtualization --&gt;</code></pre>\r\n </div>\r\n <div style=\"min-width:0\">\r\n <strong>After - <code>&lt;SvGrid&gt;</code>, same tokens</strong>\r\n <pre style=\"overflow:auto\"><code>&lt;script lang=\"ts\"&gt;\r\n import { SvGrid, type ColumnDef } from '@svgrid/grid'\r\n import '@svgrid/grid/themes/shadcn.css' // 1 line to match shadcn\r\n\r\n let rows = [/* ...same orders... */]\r\n const columns: ColumnDef[] = [\r\n { id: 'id', field: 'id', header: 'Order' },\r\n { id: 'customer', field: 'customer', header: 'Customer' },\r\n { id: 'amount', field: 'amount', header: 'Amount', type: 'number' },\r\n ]\r\n&lt;/script&gt;\r\n\r\n&lt;SvGrid\r\n data={rows}\r\n {columns}\r\n sortable\r\n filterable\r\n class=\"rounded-md border\" /&gt;\r\n&lt;!-- sortable, filterable, virtualized, keyboard + a11y --&gt;</code></pre>\r\n </div>\r\n</div>\r\n\r\nThe `@svgrid/grid/themes/shadcn.css` import above is the fastest path:\r\na ready-made preset that mirrors shadcn's zinc-neutral palette in light\r\nand dark, toggled by the same `[data-theme='dark']` attribute you\r\nalready use. Import it once and you are done.\r\n\r\n## Inherit your *exact* theme with the live token bridge\r\n\r\nThe preset matches the default shadcn look. If you have customized your\r\ntheme - a different `--primary`, a wider `--radius`, a brand font - skip\r\nthe preset and bridge SvGrid's tokens to *your* variables instead. Now\r\nthe grid tracks whatever your app's tokens resolve to, in real time.\r\n\r\nshadcn stores its palette as bare HSL channels (`--background` holds\r\n`0 0% 100%`, not `hsl(0 0% 100%)`), so wrap each one in `hsl()` at the\r\npoint of assignment:\r\n\r\n```css\r\n/* Scope to a wrapper so only grids inside it inherit the map.\r\n * Move it to :root in app.css to theme every grid on the site. */\r\n.sg-shadcn {\r\n --sg-bg: hsl(var(--background));\r\n --sg-fg: hsl(var(--foreground));\r\n --sg-border: hsl(var(--border));\r\n --sg-header-bg: hsl(var(--muted));\r\n --sg-header-fg: hsl(var(--foreground));\r\n --sg-row-alt-bg: hsl(var(--muted) / 0.3);\r\n --sg-row-hover-bg: hsl(var(--accent));\r\n --sg-selection-bg: hsl(var(--primary) / 0.15);\r\n --sg-accent: hsl(var(--primary));\r\n --sg-focus-ring: 0 0 0 2px hsl(var(--ring));\r\n --sg-scrollbar-bg: hsl(var(--background));\r\n --sg-scrollbar-thumb: hsl(var(--muted-foreground) / 0.4);\r\n --sg-scrollbar-thumb-hover: hsl(var(--muted-foreground) / 0.6);\r\n --sg-font: var(--font-sans, sans-serif);\r\n --sg-radius: var(--radius);\r\n}\r\n```\r\n\r\n```svelte\r\n<div class=\"sg-shadcn\">\r\n <SvGrid {data} {columns} sortable filterable rowHeight={40} />\r\n</div>\r\n```\r\n\r\nRow height is a prop, not a token - the virtualizer needs it as a\r\nnumber, so it cannot come from the stylesheet above.\r\n\r\n**Always wrap in `hsl()`.** Writing `--sg-accent: var(--primary)`\r\npasses bare HSL channels straight to the grid, which expects a complete\r\ncolour value - the result is an invalid colour (usually transparent).\r\n\r\n## Dark mode is automatic\r\n\r\nshadcn already redefines its tokens under `.dark`. Because every\r\n`--sg-*` value above is expressed *in terms of* those tokens, the grid\r\nflips with the rest of your app the instant `.dark` is toggled on\r\n`<html>` - no listener, no reactive statement, no API call. SvGrid\r\nreads `--sg-*` from the DOM at paint time, so mid-session theme\r\nswitches repaint the grid for free.\r\n\r\nThis also means nested themes work: put `.dark` on one panel and grids\r\ninside it go dark while the rest of the page stays light.\r\n\r\n## Custom cells with shadcn components\r\n\r\nCell snippets are plain Svelte, so you can render shadcn components\r\ndirectly inside the grid - a `Badge` for status, a `Button` for row\r\nactions:\r\n\r\n```svelte\r\n{#snippet StatusCell({ value }: { value: string })}\r\n <Badge variant={value === 'delivered' ? 'default' : 'secondary'}>\r\n {value}\r\n </Badge>\r\n{/snippet}\r\n\r\n{#snippet RowActions({ row })}\r\n <Button variant=\"ghost\" size=\"sm\" onclick={() => edit(row)}>Edit</Button>\r\n{/snippet}\r\n```\r\n\r\nAssign the snippet to a column's `cell` field\r\n(`{ id: 'status', field: 'status', header: 'Status', cell: StatusCell }`)\r\nand it renders in every row, inheriting the same theme context.\r\n\r\n## A note on OKLCH\r\n\r\nRecent shadcn-svelte releases have been migrating tokens from bare HSL\r\nchannels to OKLCH. If your `app.css` defines `--primary` as\r\n`oklch(...)`, use `oklch(var(--primary) / 0.15)` in the bridge instead\r\nof `hsl(...)`. Check the format of `--background` in your `app.css`\r\nbefore writing the map; open DevTools and confirm the computed value of\r\n`--sg-bg` is a real colour, not transparent, after any shadcn upgrade.\r\n\r\n## Frequently asked questions\r\n\r\n### Does SvGrid work with shadcn-svelte?\r\n\r\nYes. Import the `@svgrid/grid/themes/shadcn.css` preset for the default\r\nshadcn look, or bridge SvGrid's `--sg-*` tokens to your own\r\n`--background` / `--foreground` / `--primary` variables to inherit your\r\nexact customized theme. Both handle dark mode through the same\r\n`.dark` / `[data-theme='dark']` toggle you already use.\r\n\r\n### Do I need to duplicate my theme for the grid?\r\n\r\nNo. The whole point of the token bridge is that there is one source of\r\ntruth - your shadcn variables. The grid reads them; it does not copy\r\nthem.\r\n\r\n### Will dark mode break?\r\n\r\nNo, as long as your bridge expresses `--sg-*` in terms of shadcn tokens\r\n(`hsl(var(--background))`) rather than hard-coded colours. The cascade\r\nhandles the flip.\r\n\r\n## See also\r\n\r\n- [Design tokens](./tokens.md) - the full `--sg-*` surface and the 19\r\n built-in presets (`shadcn`, `tailwind`, `material`, ...)\r\n- [Tailwind integration](./tailwind.md) - wiring tokens through\r\n Tailwind's `theme(...)` layer\r\n- [Theme integrations](https://svgrid.com/demos/74-theme-integrations/)\r\n demo - shadcn and four other design systems, side by side, light and\r\n dark\r\n"
3798
3816
  },
3799
3817
  {
3800
3818
  "slug": "help/skill",
@@ -3830,7 +3848,7 @@ export const docs = [
3830
3848
  "slug": "help/tailwind",
3831
3849
  "path": "docs/help/tailwind.md",
3832
3850
  "title": "Tailwind integration",
3833
- "markdown": "# Tailwind integration\n\nSvGrid was built alongside Tailwind v4 (the gallery is the proof). The\ntwo compose cleanly because they have **non-overlapping concerns**:\n<div data-docs-demo=\"10-custom-cells-and-themes\" data-height=\"540\"></div>\n\n- Tailwind styles your *page* - controls, buttons, sidebar, modal,\n spacing, typography.\n- SvGrid ships its own scoped class names (`.sv-grid-*`) and reads\n every visual value from a small set of CSS custom properties\n (`--sg-*`) - so you re-theme it with **CSS variables**, not Tailwind\n utilities.\n\nYou don't put `class=\"bg-slate-50\"` on grid internals. You set\n`--sg-bg: theme(colors.slate.50)` once and the grid inherits.\n\n## Install\n\n```bash\npnpm add -D tailwindcss @tailwindcss/postcss autoprefixer postcss\n```\n\n`postcss.config.cjs`:\n\n```js\nmodule.exports = {\n plugins: {\n '@tailwindcss/postcss': {},\n autoprefixer: {},\n },\n}\n```\n\n`tailwind.config.cjs`:\n\n```js\nmodule.exports = {\n content: ['./index.html', './src/**/*.{ts,svelte}'],\n theme: { extend: {} },\n plugins: [],\n}\n```\n\nYour global stylesheet (`src/index.css`):\n\n```css\n@import 'tailwindcss';\n\n/* See \"Dark mode\" below - Tailwind's `dark:` follows whatever attribute\n * your app uses for theme. The gallery uses html[data-theme='dark']. */\n@custom-variant dark (&:where(html[data-theme='dark'], html[data-theme='dark'] *));\n```\n\nThat's it - the grid's class names ship as part of the component; you\ndon't need a Tailwind plugin or a `safelist` entry.\n\n## The theming surface: `--sg-*` custom properties\n\nSvGrid's stylesheet uses CSS custom properties everywhere a colour, a\nsize, or a hover effect appears. The defaults live in the published\nCSS; you override by declaring the variables at any level **above** the\ngrid (`:root` for the whole app, or on a wrapper `<div>` for one grid\ninstance).\n\nThe full surface (see the gallery's\n[`src/index.css`](../../examples/src/index.css) for live values):\n\n| Token | What it paints |\n| ----- | -------------- |\n| `--sg-bg` | Cell background |\n| `--sg-fg` | Cell text |\n| `--sg-muted` | Secondary text (footers, subtitles) |\n| `--sg-border` | Cell + header borders, scrollbar separators |\n| `--sg-header-bg` / `--sg-header-fg` | Header row |\n| `--sg-row-alt-bg` | Zebra rows |\n| `--sg-row-hover-bg` | Row + cell hover |\n| `--sg-row-height` | Row height (read by the wrapper's `rowHeight` prop) |\n| `--sg-selection-bg` | Selected cell / row tint |\n| `--sg-accent` | Sort indicator, focus ring, primary buttons |\n| `--sg-focus-ring` | Keyboard focus outline |\n| `--sg-input-bg` / `--sg-input-border` | Inline editor + filter inputs |\n| `--sg-pill-active`, `--sg-pill-pending`, `--sg-pill-inactive` (+ `-fg` variants) | Status pills |\n| `--sg-scrollbar-*` | The custom-painted scrollbars (10+ tokens for arrow / thumb / hover) |\n\nTailwind's `theme(...)` works inside these declarations, so you can\nkeep your design tokens in the Tailwind config and reference them once:\n\n```css\n:root {\n --sg-bg: theme(colors.white);\n --sg-fg: theme(colors.slate.900);\n --sg-border: theme(colors.slate.200);\n --sg-header-bg: theme(colors.slate.100);\n --sg-row-alt-bg: theme(colors.slate.50);\n --sg-row-hover-bg: theme(colors.indigo.50);\n --sg-accent: theme(colors.blue.600);\n}\n```\n\n## Dark mode\n\nThe grid is dark-mode-aware by re-declaring the same tokens under a\nselector for \"dark\":\n\n```css\nhtml[data-theme='dark'] {\n --sg-bg: theme(colors.slate.900);\n --sg-fg: theme(colors.slate.100);\n --sg-border: theme(colors.slate.700);\n --sg-header-bg: theme(colors.slate.800);\n --sg-row-alt-bg: theme(colors.slate.800);\n --sg-row-hover-bg: theme(colors.slate.700);\n --sg-accent: theme(colors.blue.400);\n color-scheme: dark;\n}\n```\n\nThe gallery's `App.svelte` writes the active theme into\n`document.documentElement.dataset.theme`, and the `@custom-variant`\ndeclaration above makes Tailwind's `dark:` modifier follow the same\nattribute. Result: Tailwind utilities and SvGrid tokens flip together.\n\n```svelte\n<button class=\"bg-white dark:bg-slate-900\"> <!-- Tailwind -->\n switch theme\n</button>\n<SvGrid {...props} /> <!-- inherits --sg-* from html[data-theme] -->\n```\n\n## Per-instance theming\n\nBecause the tokens are CSS custom properties they cascade. To restyle\na single grid, wrap it in a `<div>` that sets its own values:\n\n```svelte\n<div style=\"--sg-bg: #ffffff; --sg-accent: #db2777;\">\n <SvGrid {data} {columns} features={features} />\n</div>\n```\n\nThe [`10-custom-cells-and-themes` demo](../../examples/src/demos/10-custom-cells-and-themes.svelte)\nshows this pattern with three full palettes (light / dark / high-contrast)\napplied via a `style=\"...\"` per the user's pick.\n\n## When you *do* need to override a class\n\nSome things aren't tokens - column-resize handle width, the funnel\nbutton hover opacity, pill paddings. The grid's class names are\ndeliberately stable so you can target them from your global CSS\n(NOT through `@apply` - the grid lives outside the Tailwind\npurge pass):\n\n```css\n.sv-grid-resize-handle {\n width: 8px; /* default is 5px */\n}\n\n.sv-grid-col-filter-btn {\n opacity: 0; /* hide funnels unless hovered */\n}\n.sv-grid-column:hover .sv-grid-col-filter-btn,\n.sv-grid-col-filter-btn.is-active {\n opacity: 1;\n}\n\n.sv-grid-cell[data-align='right'] {\n font-variant-numeric: tabular-nums;\n}\n```\n\nAdd these rules to your global stylesheet, *after* `@import 'tailwindcss';`,\nso Tailwind's preflight + utilities load first and your overrides win\non equal-specificity ties.\n\n## Anti-patterns\n\n**Don't put Tailwind utility classes on the grid's children.** The\ndefault renderer owns those nodes; your classes will get clobbered on\nre-render or row-virtualisation recycle. Use the `--sg-*` tokens or\ntarget the stable `.sv-grid-*` class names.\n\n**Don't `@apply` inside grid selectors.** `@apply` reads Tailwind's\npreflight scope. Mixed with the grid's component-scoped CSS the\nspecificity gets weird. Plain property declarations (`background:\ntheme(colors.slate.50)`) are more predictable.\n\n**Don't fight the column widths in CSS.** Set them in your\n`ColumnDef`s. The wrapper uses `style=\"width: Npx; min-width: Npx;\nmax-width: Npx;\"` and the layout will not respond to a Tailwind utility\non the `<th>`.\n\n## See also\n\n- [Why headless?](../why-headless.md) - the architectural reason the\n theming surface looks like this\n- [Getting started](../getting-started.md) - end-to-end gallery setup\n- [Styling cells](./cells/styling-cells.md) - cell-level overrides\n- [Styling rows](./rows/styling-rows.md) - row-level overrides\n- Demo [`10-custom-cells-and-themes`](../../examples/src/demos/10-custom-cells-and-themes.svelte) -\n three palettes applied via `style=\"--sg-*: ...\"`\n\n## Frequently asked questions\n\n### Does SvGrid work with Tailwind CSS?\n\nYes - it was built alongside Tailwind v4. They compose cleanly because they have\nnon-overlapping concerns: Tailwind styles your layout and custom cell content,\nwhile the grid's internals are themed through `--sg-*` CSS variables.\n\n### How do I theme or re-skin the grid?\n\nSet the `--sg-*` custom properties - per instance via `style=\"--sg-bg: ...\"` or\nglobally in your CSS. They control borders, header background, zebra rows, hover,\nselection, and density, including dark mode.\n\n### Do I need Tailwind to use SvGrid?\n\nNo. The grid ships a default theme and depends only on its own CSS variables.\nTailwind is one convenient way to style around it, not a requirement.\n"
3851
+ "markdown": "# Tailwind integration\n\nSvGrid was built alongside Tailwind v4 (the gallery is the proof). The\ntwo compose cleanly because they have **non-overlapping concerns**:\n<div data-docs-demo=\"10-custom-cells-and-themes\" data-height=\"540\"></div>\n\n- Tailwind styles your *page* - controls, buttons, sidebar, modal,\n spacing, typography.\n- SvGrid ships its own scoped class names (`.sv-grid-*`) and reads\n every visual value from a small set of CSS custom properties\n (`--sg-*`) - so you re-theme it with **CSS variables**, not Tailwind\n utilities.\n\nYou don't put `class=\"bg-slate-50\"` on grid internals. You set\n`--sg-bg: theme(colors.slate.50)` once and the grid inherits.\n\n## Install\n\n```bash\npnpm add -D tailwindcss @tailwindcss/postcss autoprefixer postcss\n```\n\n`postcss.config.cjs`:\n\n```js\nmodule.exports = {\n plugins: {\n '@tailwindcss/postcss': {},\n autoprefixer: {},\n },\n}\n```\n\n`tailwind.config.cjs`:\n\n```js\nmodule.exports = {\n content: ['./index.html', './src/**/*.{ts,svelte}'],\n theme: { extend: {} },\n plugins: [],\n}\n```\n\nYour global stylesheet (`src/index.css`):\n\n```css\n@import 'tailwindcss';\n\n/* See \"Dark mode\" below - Tailwind's `dark:` follows whatever attribute\n * your app uses for theme. The gallery uses html[data-theme='dark']. */\n@custom-variant dark (&:where(html[data-theme='dark'], html[data-theme='dark'] *));\n```\n\nThat's it - the grid's class names ship as part of the component; you\ndon't need a Tailwind plugin or a `safelist` entry.\n\n## The theming surface: `--sg-*` custom properties\n\nSvGrid's stylesheet uses CSS custom properties everywhere a colour, a\nsize, or a hover effect appears. The defaults live in the published\nCSS; you override by declaring the variables at any level **above** the\ngrid (`:root` for the whole app, or on a wrapper `<div>` for one grid\ninstance).\n\nThe full surface (see the gallery's\n[`src/index.css`](../../examples/src/index.css) for live values):\n\n| Token | What it paints |\n| ----- | -------------- |\n| `--sg-bg` | Cell background |\n| `--sg-fg` | Cell text |\n| `--sg-muted` | Secondary text (footers, subtitles) |\n| `--sg-border` | Cell + header borders, scrollbar separators |\n| `--sg-header-bg` / `--sg-header-fg` | Header row |\n| `--sg-row-alt-bg` | Zebra rows |\n| `--sg-row-hover-bg` | Row + cell hover |\n| `--sg-selection-bg` | Selected cell / row tint |\n| `--sg-accent` | Sort indicator, focus ring, primary buttons |\n| `--sg-focus-ring` | Keyboard focus outline |\n| `--sg-input-bg` / `--sg-input-border` | Inline editor + filter inputs |\n| `--sg-pill-active`, `--sg-pill-pending`, `--sg-pill-inactive` (+ `-fg` variants) | Status pills |\n| `--sg-scrollbar-*` | The custom-painted scrollbars (10+ tokens for arrow / thumb / hover) |\n\nTailwind's `theme(...)` works inside these declarations, so you can\nkeep your design tokens in the Tailwind config and reference them once:\n\n```css\n:root {\n --sg-bg: theme(colors.white);\n --sg-fg: theme(colors.slate.900);\n --sg-border: theme(colors.slate.200);\n --sg-header-bg: theme(colors.slate.100);\n --sg-row-alt-bg: theme(colors.slate.50);\n --sg-row-hover-bg: theme(colors.indigo.50);\n --sg-accent: theme(colors.blue.600);\n}\n```\n\n## Dark mode\n\nThe grid is dark-mode-aware by re-declaring the same tokens under a\nselector for \"dark\":\n\n```css\nhtml[data-theme='dark'] {\n --sg-bg: theme(colors.slate.900);\n --sg-fg: theme(colors.slate.100);\n --sg-border: theme(colors.slate.700);\n --sg-header-bg: theme(colors.slate.800);\n --sg-row-alt-bg: theme(colors.slate.800);\n --sg-row-hover-bg: theme(colors.slate.700);\n --sg-accent: theme(colors.blue.400);\n color-scheme: dark;\n}\n```\n\nThe gallery's `App.svelte` writes the active theme into\n`document.documentElement.dataset.theme`, and the `@custom-variant`\ndeclaration above makes Tailwind's `dark:` modifier follow the same\nattribute. Result: Tailwind utilities and SvGrid tokens flip together.\n\n```svelte\n<button class=\"bg-white dark:bg-slate-900\"> <!-- Tailwind -->\n switch theme\n</button>\n<SvGrid {...props} /> <!-- inherits --sg-* from html[data-theme] -->\n```\n\n## Per-instance theming\n\nBecause the tokens are CSS custom properties they cascade. To restyle\na single grid, wrap it in a `<div>` that sets its own values:\n\n```svelte\n<div style=\"--sg-bg: #ffffff; --sg-accent: #db2777;\">\n <SvGrid {data} {columns} features={features} />\n</div>\n```\n\nThe [`10-custom-cells-and-themes` demo](../../examples/src/demos/10-custom-cells-and-themes.svelte)\nshows this pattern with three full palettes (light / dark / high-contrast)\napplied via a `style=\"...\"` per the user's pick.\n\n## When you *do* need to override a class\n\nSome things aren't tokens - column-resize handle width, the funnel\nbutton hover opacity, pill paddings. The grid's class names are\ndeliberately stable so you can target them from your global CSS\n(NOT through `@apply` - the grid lives outside the Tailwind\npurge pass):\n\n```css\n.sv-grid-resize-handle {\n width: 8px; /* default is 5px */\n}\n\n.sv-grid-col-filter-btn {\n opacity: 0; /* hide funnels unless hovered */\n}\n.sv-grid-column:hover .sv-grid-col-filter-btn,\n.sv-grid-col-filter-btn.is-active {\n opacity: 1;\n}\n\n.sv-grid-cell[data-align='right'] {\n font-variant-numeric: tabular-nums;\n}\n```\n\nAdd these rules to your global stylesheet, *after* `@import 'tailwindcss';`,\nso Tailwind's preflight + utilities load first and your overrides win\non equal-specificity ties.\n\n## Anti-patterns\n\n**Don't put Tailwind utility classes on the grid's children.** The\ndefault renderer owns those nodes; your classes will get clobbered on\nre-render or row-virtualisation recycle. Use the `--sg-*` tokens or\ntarget the stable `.sv-grid-*` class names.\n\n**Don't `@apply` inside grid selectors.** `@apply` reads Tailwind's\npreflight scope. Mixed with the grid's component-scoped CSS the\nspecificity gets weird. Plain property declarations (`background:\ntheme(colors.slate.50)`) are more predictable.\n\n**Don't fight the column widths in CSS.** Set them in your\n`ColumnDef`s. The wrapper uses `style=\"width: Npx; min-width: Npx;\nmax-width: Npx;\"` and the layout will not respond to a Tailwind utility\non the `<th>`.\n\n## See also\n\n- [Why headless?](../why-headless.md) - the architectural reason the\n theming surface looks like this\n- [Getting started](../getting-started.md) - end-to-end gallery setup\n- [Styling cells](./cells/styling-cells.md) - cell-level overrides\n- [Styling rows](./rows/styling-rows.md) - row-level overrides\n- Demo [`10-custom-cells-and-themes`](../../examples/src/demos/10-custom-cells-and-themes.svelte) -\n three palettes applied via `style=\"--sg-*: ...\"`\n\n## Frequently asked questions\n\n### Does SvGrid work with Tailwind CSS?\n\nYes - it was built alongside Tailwind v4. They compose cleanly because they have\nnon-overlapping concerns: Tailwind styles your layout and custom cell content,\nwhile the grid's internals are themed through `--sg-*` CSS variables.\n\n### How do I theme or re-skin the grid?\n\nSet the `--sg-*` custom properties - per instance via `style=\"--sg-bg: ...\"` or\nglobally in your CSS. They control borders, header background, zebra rows, hover,\nselection, and density, including dark mode.\n\n### Do I need Tailwind to use SvGrid?\n\nNo. The grid ships a default theme and depends only on its own CSS variables.\nTailwind is one convenient way to style around it, not a requirement.\n"
3834
3852
  },
3835
3853
  {
3836
3854
  "slug": "help/testing-and-quality",
@@ -3842,13 +3860,13 @@ export const docs = [
3842
3860
  "slug": "help/testing",
3843
3861
  "path": "docs/help/testing.md",
3844
3862
  "title": "Testing your grid",
3845
- "markdown": "# Testing your grid\n\nHow to write tests that catch regressions before they ship. SvGrid is\ndesigned for both **fast unit tests** (Layer 2, the headless engine,\nruns in pure node) and **slow but accurate browser tests** (Layer 3,\nthe `<SvGrid>` component, needs a real DOM or jsdom).\n\n![Three test levels, cheapest first: the headless engine with no DOM at the base, a component test that renders the grid in the middle, and end to end at the top.](/docs-media/grid-testing.svg)\n\n## Test pyramid for a grid app\n\n```\n /─────────────\\\n / Playwright \\ slow, ~5-15 / page\n / end-to-end \\ accurate\n /───────────────────\\\n / jsdom + svelte- \\\n / testing-library \\ ~15-60 / file\n / component tests \\\n /───────────────────────────\\\n / vitest engine tests \\ fast, ~50-200 / file\n / (Layer 2, no DOM, pure) \\ pure JS\n /─────────────────────────────────\\\n```\n\nYou want a wide base of fast tests (the engine surface), a narrower\nmiddle layer of component tests (mount + interact with the renderer),\nand a small top layer of e2e tests for the journeys that actually\nmatter to your users.\n\n## Engine tests (Layer 2, vitest)\n\nEvery helper in `@svgrid/grid` is a pure function. Test them\nwithout a DOM.\n\n```ts\nimport { describe, it, expect } from 'vitest'\nimport {\n createSvGrid, tableFeatures, rowSortingFeature,\n createCoreRowModel, createSortedRowModel, sortFns,\n} from '@svgrid/grid'\n\ndescribe('sort behaviour', () => {\n it('sorts by a single column ascending', () => {\n type Row = { id: number; name: string }\n const features = tableFeatures({ rowSortingFeature })\n const grid = createSvGrid<typeof features, Row>({\n data: [\n { id: 1, name: 'Charlie' },\n { id: 2, name: 'Alice' },\n { id: 3, name: 'Bob' },\n ],\n columns: [\n { field: 'id', header: 'ID' },\n { field: 'name', header: 'Name' },\n ],\n _features: features,\n // Opt into the row models you assert on. The headless grid composes\n // them explicitly, so a grid built without `sortedRowModel` returns\n // rows in source order no matter what `sorting` says.\n _rowModels: {\n coreRowModel: createCoreRowModel(),\n sortedRowModel: createSortedRowModel(sortFns),\n },\n state: { sorting: [{ id: 'name', desc: false }] },\n })\n const visible = grid.getRowModel().rows.map((r) => r.original.name)\n expect(visible).toEqual(['Alice', 'Bob', 'Charlie'])\n })\n})\n```\n\nEngine tests run at ~10k assertions/second on a modern laptop. The\n`@svgrid/grid` package itself ships [hundreds of these](https://github.com/sv-grid/sv-grid/tree/main/packages/grid/src) -\nyou can model yours after them.\n\n## Enterprise feature tests (vitest + jsdom)\n\nThe Enterprise helpers need `jsdom` because `importData` calls `Blob.text()`\nand `exportData` builds an `<a download>`. Set vitest's `environment`\nto `'jsdom'` for these files.\n\n```ts\nimport { describe, it, expect, beforeEach } from 'vitest'\nimport { importData, setLicenseKey } from '@svgrid/enterprise'\n\nbeforeEach(() => setLicenseKey('SVENTERPRISE-DEV-TEST'))\n\ndescribe('CSV import', () => {\n it('parses, coerces types, and rejects negative prices', async () => {\n const csv = 'id,price\\n1,-5\\n2,10\\n'\n const fakeApi = makeFakeApi() // see below\n const result = await importData(fakeApi, {\n file: csv,\n format: 'csv',\n validator: (row) => row.price < 0\n ? [{ field: 'price', message: 'must be >= 0' }]\n : [],\n })\n expect(result.rows).toHaveLength(2)\n expect(result.errors).toHaveLength(1)\n expect(result.errors[0].rowIndex).toBe(0)\n })\n})\n```\n\nThe 48-test suite in `packages/enterprise/src/*.test.ts` shows the\nfull pattern, including a `fakeApi` stub you can copy.\n\n## Component tests (svelte-testing-library + jsdom)\n\nFor \"does the grid actually render the rows\", mount the `<SvGrid>`\ncomponent in jsdom:\n\n```ts\nimport { render } from '@testing-library/svelte'\nimport { describe, it, expect } from 'vitest'\nimport { SvGrid, tableFeatures, rowSortingFeature, type ColumnDef } from '@svgrid/grid'\n\ntype Row = { id: number; name: string }\n\nconst features = tableFeatures({ rowSortingFeature })\nconst columns: ColumnDef<typeof features, Row>[] = [\n { field: 'id', header: 'ID' },\n { field: 'name', header: 'Name' },\n]\n\ndescribe('<SvGrid>', () => {\n it('renders one row per data entry', () => {\n const { container } = render(SvGrid, {\n props: {\n data: [{ id: 1, name: 'Ada' }, { id: 2, name: 'Linus' }],\n columns,\n features,\n },\n })\n const bodyRows = container.querySelectorAll('tbody tr')\n expect(bodyRows.length).toBe(2)\n })\n})\n```\n\nA few caveats:\n\n- **Vitest config:** `environment: 'jsdom'` plus\n `resolve.conditions: ['browser']` so vitest picks Svelte's client\n build. The `@svgrid/grid` repo's `vite.config.ts` shows the\n exact knobs.\n- **No virtualization in jsdom.** jsdom returns `0` for every layout\n metric, so the row virtualizer never advances. Test on small\n datasets (< 50 rows) at this layer; push virtualization tests to\n Playwright.\n- **No clipboard.** `document.execCommand('copy')` is a no-op in\n jsdom; if you're testing copy/paste, mock the clipboard or skip\n to Playwright.\n\n## End-to-end (Playwright)\n\nFor real-DOM behaviours: virtualization, scroll-driven chunk loading,\nfocus traps, clipboard, the `<SvGrid>`'s `ResizeObserver`-driven\nlayout.\n\n```ts\nimport { test, expect } from '@playwright/test'\n\ntest('Sort + filter + paginate together', async ({ page }) => {\n await page.goto('http://localhost:5180/#/demos/02-sort-filter-paginate')\n // Sort by Customer\n await page.locator('thead th', { hasText: 'Customer' }).click()\n // First row should now be alphabetically first.\n const first = await page.locator('tbody tr').first().textContent()\n expect(first?.startsWith('A')).toBe(true)\n // Apply a filter\n await page.locator('thead th', { hasText: 'Region' }).locator('button[aria-label*=Filter]').click()\n await page.locator('.sv-grid-menu-option', { hasText: 'EMEA' }).click()\n // Row count drops\n const visible = await page.locator('tbody tr').count()\n expect(visible).toBeLessThan(50)\n})\n```\n\nThe 53-demo gallery is the easiest target for e2e: every demo is a\nURL you can navigate, every behaviour is reachable from the keyboard.\nMirror your in-app test flows against a paired demo first; it surfaces\nbugs at the API layer before they hit your app's code.\n\n## Accessibility regression tests\n\nWrap [axe-core](https://github.com/dequelabs/axe-core) into your\nPlaywright suite to catch contrast / role / label regressions on every\ncommit:\n\n```ts\nimport { test, expect } from '@playwright/test'\nimport { injectAxe, checkA11y } from 'axe-playwright'\n\ntest('a11y: quick-start grid', async ({ page }) => {\n await page.goto('http://localhost:5180/#/demos/01-quick-start')\n await injectAxe(page)\n await checkA11y(page, '.sv-grid-shell', {\n detailedReport: false,\n axeOptions: {\n runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] },\n },\n })\n})\n```\n\nThe grid passes axe's WCAG 2.1 AA rules at the default theme; if your\ncustom theme breaks contrast, this test fails immediately.\n\n## Visual regression\n\nFor the small set of pixels that matter (header bar height, focus ring\nwidth, the \"selected row\" highlight), Playwright's `toHaveScreenshot()`\nis a good fit:\n\n```ts\ntest('focused cell matches the design system ring', async ({ page }) => {\n await page.goto('http://localhost:5180/#/demos/01-quick-start')\n await page.locator('tbody tr').first().locator('td').first().click()\n await expect(page.locator('.sv-grid-cell-active')).toHaveScreenshot('active-cell.png')\n})\n```\n\nPin the screenshot to a tight selector and a 1x device-pixel-ratio so\nyour team's various GPUs don't churn the baseline.\n\n## Performance regression\n\nThe benchmark script (`pnpm bench`) is meant to be run on every release.\nFor your own app, capture two numbers in CI:\n\n1. **Time to first paint** on your largest grid - run a Playwright\n trace, look at the timing of the first `tbody tr` appearing.\n2. **Sustained scroll p95 frame time** - use Playwright's\n `page.evaluate(() => performance.timing)` or the Chrome DevTools\n protocol's `Performance.getMetrics`.\n\nBoth can fail your CI with a 10% deviation threshold. See\n[Performance benchmarks](./benchmarks.md) for the documented numbers\non the published package.\n\n## Test data fixtures\n\nA common pitfall: ad-hoc test rows that drift across tests until\nnothing reuses them.\n\n```ts\n// tests/fixtures/orders.ts\nexport function makeOrder(overrides: Partial<Order> = {}): Order {\n return {\n id: 1,\n customer: 'Acme',\n total: 100,\n placedAt: '2024-01-01',\n status: 'pending',\n ...overrides,\n }\n}\n```\n\nEvery test uses `makeOrder()` with the diffs it cares about. When the\ndomain shape changes, ONE fixture changes, not 200 tests.\n\n## What NOT to do\n\n- **Don't grep DOM classes.** `.sv-grid-cell-active` is implementation\n detail (see [API stability](./api-stability.md)). Tests against it\n break on minor releases. Prefer `aria-selected=\"true\"` or a custom\n `data-testid`.\n- **Don't snapshot the entire rendered HTML.** Internal markup changes\n per release; snapshots become churn. Snapshot small specific\n fragments instead.\n- **Don't test the framework.** SvGrid is well-tested at the package\n level; you don't need to verify that \"click on a sort header sorts\".\n Test YOUR business rules - \"rejected orders never appear in the\n active queue\".\n\n## See also\n\n- [API stability](./api-stability.md) - what's safe to assert against.\n- [Architecture overview](./architecture.md) - which layer to test at.\n- [Performance benchmarks](./benchmarks.md) - reference numbers you\n can use as CI thresholds.\n- [Accessibility](./accessibility.md) - the a11y contract these tests\n enforce.\n"
3863
+ "markdown": "# Testing your grid\r\n\r\nHow to write tests that catch regressions before they ship. SvGrid is\r\ndesigned for both **fast unit tests** (Layer 2, the headless engine,\r\nruns in pure node) and **slow but accurate browser tests** (Layer 3,\r\nthe `<SvGrid>` component, needs a real DOM or jsdom).\r\n\r\n![Three test levels, cheapest first: the headless engine with no DOM at the base, a component test that renders the grid in the middle, and end to end at the top.](/docs-media/grid-testing.svg)\r\n\r\n## Test pyramid for a grid app\r\n\r\n```\r\n /─────────────\\\r\n / Playwright \\ slow, ~5-15 / page\r\n / end-to-end \\ accurate\r\n /───────────────────\\\r\n / jsdom + svelte- \\\r\n / testing-library \\ ~15-60 / file\r\n / component tests \\\r\n /───────────────────────────\\\r\n / vitest engine tests \\ fast, ~50-200 / file\r\n / (Layer 2, no DOM, pure) \\ pure JS\r\n /─────────────────────────────────\\\r\n```\r\n\r\nYou want a wide base of fast tests (the engine surface), a narrower\r\nmiddle layer of component tests (mount + interact with the renderer),\r\nand a small top layer of e2e tests for the journeys that actually\r\nmatter to your users.\r\n\r\n## Engine tests (Layer 2, vitest)\r\n\r\nEvery helper in `@svgrid/grid` is a pure function. Test them\r\nwithout a DOM.\r\n\r\n```ts\r\nimport { describe, it, expect } from 'vitest'\r\nimport {\r\n createSvGrid, tableFeatures, rowSortingFeature,\r\n createCoreRowModel, createSortedRowModel, sortFns,\r\n} from '@svgrid/grid'\r\n\r\ndescribe('sort behaviour', () => {\r\n it('sorts by a single column ascending', () => {\r\n type Row = { id: number; name: string }\r\n const features = tableFeatures({ rowSortingFeature })\r\n const grid = createSvGrid<typeof features, Row>({\r\n data: [\r\n { id: 1, name: 'Charlie' },\r\n { id: 2, name: 'Alice' },\r\n { id: 3, name: 'Bob' },\r\n ],\r\n columns: [\r\n { field: 'id', header: 'ID' },\r\n { field: 'name', header: 'Name' },\r\n ],\r\n _features: features,\r\n // Opt into the row models you assert on. The headless grid composes\r\n // them explicitly, so a grid built without `sortedRowModel` returns\r\n // rows in source order no matter what `sorting` says.\r\n _rowModels: {\r\n coreRowModel: createCoreRowModel(),\r\n sortedRowModel: createSortedRowModel(sortFns),\r\n },\r\n state: { sorting: [{ id: 'name', desc: false }] },\r\n })\r\n const visible = grid.getRowModel().rows.map((r) => r.original.name)\r\n expect(visible).toEqual(['Alice', 'Bob', 'Charlie'])\r\n })\r\n})\r\n```\r\n\r\nEngine tests run at ~10k assertions/second on a modern laptop. The\r\n`@svgrid/grid` package itself ships [hundreds of these](https://github.com/sv-grid/sv-grid/tree/main/packages/grid/src) -\r\nyou can model yours after them.\r\n\r\n## Enterprise feature tests (vitest + jsdom)\r\n\r\nThe Enterprise helpers need `jsdom` because `importData` calls `Blob.text()`\r\nand `exportData` builds an `<a download>`. Set vitest's `environment`\r\nto `'jsdom'` for these files.\r\n\r\n```ts\r\nimport { describe, it, expect, beforeEach } from 'vitest'\r\nimport { importData, setLicenseKey } from '@svgrid/enterprise'\r\n\r\nbeforeEach(() => setLicenseKey('SVENTERPRISE-DEV-TEST'))\r\n\r\ndescribe('CSV import', () => {\r\n it('parses, coerces types, and rejects negative prices', async () => {\r\n const csv = 'id,price\\n1,-5\\n2,10\\n'\r\n const fakeApi = makeFakeApi() // see below\r\n const result = await importData(fakeApi, {\r\n file: csv,\r\n format: 'csv',\r\n validator: (row) => row.price < 0\r\n ? [{ field: 'price', message: 'must be >= 0' }]\r\n : [],\r\n })\r\n expect(result.rows).toHaveLength(2)\r\n expect(result.errors).toHaveLength(1)\r\n expect(result.errors[0].rowIndex).toBe(0)\r\n })\r\n})\r\n```\r\n\r\nThe 48-test suite in `packages/enterprise/src/*.test.ts` shows the\r\nfull pattern, including a `fakeApi` stub you can copy.\r\n\r\n## Component tests (svelte-testing-library + jsdom)\r\n\r\nFor \"does the grid actually render the rows\", mount the `<SvGrid>`\r\ncomponent in jsdom:\r\n\r\n```ts\r\nimport { render } from '@testing-library/svelte'\r\nimport { describe, it, expect } from 'vitest'\r\nimport { SvGrid, tableFeatures, rowSortingFeature, type ColumnDef } from '@svgrid/grid'\r\n\r\ntype Row = { id: number; name: string }\r\n\r\nconst features = tableFeatures({ rowSortingFeature })\r\nconst columns: ColumnDef<typeof features, Row>[] = [\r\n { field: 'id', header: 'ID' },\r\n { field: 'name', header: 'Name' },\r\n]\r\n\r\ndescribe('<SvGrid>', () => {\r\n it('renders one row per data entry', () => {\r\n const { container } = render(SvGrid, {\r\n props: {\r\n data: [{ id: 1, name: 'Ada' }, { id: 2, name: 'Linus' }],\r\n columns,\r\n features,\r\n },\r\n })\r\n const bodyRows = container.querySelectorAll('tbody tr')\r\n expect(bodyRows.length).toBe(2)\r\n })\r\n})\r\n```\r\n\r\nA few caveats:\r\n\r\n- **Vitest config:** `environment: 'jsdom'` plus\r\n `resolve.conditions: ['browser']` so vitest picks Svelte's client\r\n build. The `@svgrid/grid` repo's `vite.config.ts` shows the\r\n exact knobs.\r\n- **No virtualization in jsdom.** jsdom returns `0` for every layout\r\n metric, so the row virtualizer never advances. Test on small\r\n datasets (< 50 rows) at this layer; push virtualization tests to\r\n Playwright.\r\n- **No clipboard.** `document.execCommand('copy')` is a no-op in\r\n jsdom; if you're testing copy/paste, mock the clipboard or skip\r\n to Playwright.\r\n\r\n## End-to-end (Playwright)\r\n\r\nFor real-DOM behaviours: virtualization, scroll-driven chunk loading,\r\nfocus traps, clipboard, the `<SvGrid>`'s `ResizeObserver`-driven\r\nlayout.\r\n\r\n```ts\r\nimport { test, expect } from '@playwright/test'\r\n\r\ntest('Sort + filter + paginate together', async ({ page }) => {\r\n await page.goto('http://localhost:5180/#/demos/02-sort-filter-paginate')\r\n // Sort by Customer\r\n await page.locator('thead th', { hasText: 'Customer' }).click()\r\n // First row should now be alphabetically first.\r\n const first = await page.locator('tbody tr').first().textContent()\r\n expect(first?.startsWith('A')).toBe(true)\r\n // Apply a filter\r\n await page.locator('thead th', { hasText: 'Region' }).locator('button[aria-label*=Filter]').click()\r\n await page.locator('.sv-grid-menu-option', { hasText: 'EMEA' }).click()\r\n // Row count drops\r\n const visible = await page.locator('tbody tr').count()\r\n expect(visible).toBeLessThan(50)\r\n})\r\n```\r\n\r\nThe 53-demo gallery is the easiest target for e2e: every demo is a\r\nURL you can navigate, every behaviour is reachable from the keyboard.\r\nMirror your in-app test flows against a paired demo first; it surfaces\r\nbugs at the API layer before they hit your app's code.\r\n\r\n## Accessibility regression tests\r\n\r\nWrap [axe-core](https://github.com/dequelabs/axe-core) into your\r\nPlaywright suite to catch contrast / role / label regressions on every\r\ncommit:\r\n\r\n```ts\r\nimport { test, expect } from '@playwright/test'\r\nimport { injectAxe, checkA11y } from 'axe-playwright'\r\n\r\ntest('a11y: quick-start grid', async ({ page }) => {\r\n await page.goto('http://localhost:5180/#/demos/01-quick-start')\r\n await injectAxe(page)\r\n await checkA11y(page, '.sv-grid-shell', {\r\n detailedReport: false,\r\n axeOptions: {\r\n runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] },\r\n },\r\n })\r\n})\r\n```\r\n\r\nThe default theme is built to clear axe's WCAG 2.1 AA rules, and this is\r\nthe test to prove it in your own build. If your custom theme breaks\r\ncontrast, it fails immediately.\r\n\r\n## Visual regression\r\n\r\nFor the small set of pixels that matter (header bar height, focus ring\r\nwidth, the \"selected row\" highlight), Playwright's `toHaveScreenshot()`\r\nis a good fit:\r\n\r\n```ts\r\ntest('focused cell matches the design system ring', async ({ page }) => {\r\n await page.goto('http://localhost:5180/#/demos/01-quick-start')\r\n await page.locator('tbody tr').first().locator('td').first().click()\r\n await expect(page.locator('.sv-grid-cell-active')).toHaveScreenshot('active-cell.png')\r\n})\r\n```\r\n\r\nPin the screenshot to a tight selector and a 1x device-pixel-ratio so\r\nyour team's various GPUs don't churn the baseline.\r\n\r\n## Performance regression\r\n\r\nThe benchmark script (`pnpm bench`) is meant to be run on every release.\r\nFor your own app, capture two numbers in CI:\r\n\r\n1. **Time to first paint** on your largest grid - run a Playwright\r\n trace, look at the timing of the first `tbody tr` appearing.\r\n2. **Sustained scroll p95 frame time** - use Playwright's\r\n `page.evaluate(() => performance.timing)` or the Chrome DevTools\r\n protocol's `Performance.getMetrics`.\r\n\r\nBoth can fail your CI with a 10% deviation threshold. See\r\n[Performance benchmarks](./benchmarks.md) for the documented numbers\r\non the published package.\r\n\r\n## Test data fixtures\r\n\r\nA common pitfall: ad-hoc test rows that drift across tests until\r\nnothing reuses them.\r\n\r\n```ts\r\n// tests/fixtures/orders.ts\r\nexport function makeOrder(overrides: Partial<Order> = {}): Order {\r\n return {\r\n id: 1,\r\n customer: 'Acme',\r\n total: 100,\r\n placedAt: '2024-01-01',\r\n status: 'pending',\r\n ...overrides,\r\n }\r\n}\r\n```\r\n\r\nEvery test uses `makeOrder()` with the diffs it cares about. When the\r\ndomain shape changes, ONE fixture changes, not 200 tests.\r\n\r\n## What NOT to do\r\n\r\n- **Don't grep DOM classes.** `.sv-grid-cell-active` is implementation\r\n detail (see [API stability](./api-stability.md)). Tests against it\r\n break on minor releases. Prefer `aria-selected=\"true\"` or a custom\r\n `data-testid`.\r\n- **Don't snapshot the entire rendered HTML.** Internal markup changes\r\n per release; snapshots become churn. Snapshot small specific\r\n fragments instead.\r\n- **Don't test the framework.** SvGrid is well-tested at the package\r\n level; you don't need to verify that \"click on a sort header sorts\".\r\n Test YOUR business rules - \"rejected orders never appear in the\r\n active queue\".\r\n\r\n## See also\r\n\r\n- [API stability](./api-stability.md) - what's safe to assert against.\r\n- [Architecture overview](./architecture.md) - which layer to test at.\r\n- [Performance benchmarks](./benchmarks.md) - reference numbers you\r\n can use as CI thresholds.\r\n- [Accessibility](./accessibility.md) - the a11y contract these tests\r\n enforce.\r\n"
3846
3864
  },
3847
3865
  {
3848
3866
  "slug": "help/tokens",
3849
3867
  "path": "docs/help/tokens.md",
3850
3868
  "title": "Design tokens",
3851
- "markdown": "# Design tokens\r\n\r\nEvery visual property the grid exposes for theming is a CSS custom\r\nproperty (`--sg-*`). Tokens cascade like any other CSS variable -\r\noverride at `:root`, on a wrapper element, or per-grid; the closest\r\ndeclaration wins.\r\n<div data-docs-demo=\"74-theme-integrations\" data-height=\"540\"></div>\r\n\r\n> No build step. No theme provider. No design-system lock-in. Set a\r\n> few `--sg-*` tokens and the grid re-skins immediately.\r\n\r\nLive in [demo 74 (Theme integrations)](https://svgrid.com/demos/74-theme-integrations/)\r\n- shows the same grid rendered through Ant Design, MUI, Fluent, Base\r\nWeb, and shadcn token sets.\r\n\r\n## Core surface tokens\r\n\r\nThese are the tokens 95% of integrations override. Stable; safe to\r\nship in a corporate design system.\r\n\r\n| Token | Default (light) | Default (dark) | What it paints |\r\n| ---------------------- | ---------------------- | ---------------------- | -------------------------------------------------------------------- |\r\n| `--sg-bg` | `#ffffff` | `#0f172a` | Grid background / cell fill |\r\n| `--sg-fg` | `#0f172a` | `#f1f5f9` | Primary text colour |\r\n| `--sg-muted` | `#64748b` | `#94a3b8` | Secondary text (header subtitles, placeholders, \"no data\" message) |\r\n| `--sg-border` | `#e2e8f0` | `#1e293b` | Cell borders, separators |\r\n| `--sg-header-bg` | `#f8fafc` | `#1e293b` | Column-header row background |\r\n| `--sg-header-fg` | inherits `--sg-fg` | inherits `--sg-fg` | Column-header text |\r\n| `--sg-header-border` | inherits `--sg-border` | inherits `--sg-border` | Line under the column-header row only (body cell borders unaffected) |\r\n| `--sg-row-alt-bg` | inherits `--sg-bg` | inherits `--sg-bg` | Zebra-stripe background for even-indexed rows |\r\n| `--sg-row-hover-bg` | `rgba(148,163,184,.10)`| `rgba(148,163,184,.14)`| Hover row background |\r\n| `--sg-selection-bg` | `rgba(99,102,241,.10)` | `rgba(99,102,241,.18)` | Selected row / selected cell-range fill |\r\n| `--sg-accent` | `#6366f1` | `#818cf8` | Active-cell ring, primary buttons, sort indicator |\r\n| `--sg-focus-ring` | `0 0 0 2px rgba(99,102,241,.40)` | same | Box-shadow used for the focus outline |\r\n\r\n## Layout tokens\r\n\r\n| Token | Default | What it controls |\r\n| ---------------------- | ----------- | ---------------------------------------------------------------------- |\r\n| `--sg-row-height` | `32px` | Default row height. Per-grid override via the `rowHeight` prop wins. |\r\n| `--sg-radius` | `6px` | Border radius on rounded UI inside the grid (chips, badges, menus) |\r\n| `--sg-font` | inherits | Font family used inside the grid |\r\n\r\n## Header typography tokens\r\n\r\nThe column-header label is themeable on its own so a preset can match\r\nits design system's grid header (bold, muted, small caps) without\r\ntouching the rest of the grid.\r\n\r\n| Token | Default | What it controls |\r\n| ------------------------- | ---------------------- | ------------------------------------------------------------------- |\r\n| `--sg-header-label-color` | inherits `--sg-header-fg` | Header label colour, when it should differ from the header text |\r\n| `--sg-header-weight` | `600` | Header label font weight |\r\n| `--sg-header-size` | inherits | Header label font size |\r\n| `--sg-header-transform` | `none` | Header label `text-transform` (e.g. `uppercase`) |\r\n| `--sg-header-tracking` | `normal` | Header label `letter-spacing` |\r\n| `--sg-header-min-height` | `auto` | Minimum height of a header cell |\r\n\r\n## Pinned-column tokens\r\n\r\n| Token | Default | What it paints |\r\n| ---------------------- | ----------- | ---------------------------------------------------------------------- |\r\n| `--sg-pinned-bg` | inherits `--sg-bg` | Pinned-column cell background (must be opaque to cover scroll under) |\r\n| `--sg-pinned-header-bg`| inherits `--sg-header-bg` | Pinned-column header background |\r\n| `--sg-pinned-divider` | `#cbd5e1` | 2px separator between pinned and scrolling region |\r\n| `--sg-pinned-border` | `color-mix(in oklab, var(--sg-accent) 24%, transparent)` | Top/bottom border of pinned **rows** |\r\n| `--sg-pinned-shadow-color` | `rgba(15,23,42,.22)` | Colour of the shadow a pinned column casts into the scroll area |\r\n\r\n## Surface tokens\r\n\r\n| Token | Default | What it paints |\r\n| ---------------------- | ------------------------- | ------------------------------------------------------------------ |\r\n| `--sg-bg-subtle` | inherits `--sg-header-bg` | Inset surfaces such as the master-detail region behind a nested grid |\r\n\r\n## Input + form tokens\r\n\r\n| Token | Default | What it paints |\r\n| ---------------------- | ---------------------- | -------------------------------------------------------------------- |\r\n| `--sg-input-bg` | inherits `--sg-bg` | Cell-editor `<input>` background |\r\n| `--sg-input-border` | inherits `--sg-border` | Cell-editor border |\r\n| `--sg-rating-on` | `#f59e0b` | Filled star colour in the rating editor |\r\n| `--sg-rating-empty` | `#cbd5e1` | Empty star colour |\r\n| `--sg-rating-hover` | `#fbbf24` | Star hover-state colour |\r\n\r\n## Scrollbar tokens\r\n\r\nThe grid uses a custom scrollbar so it looks consistent across OSes.\r\n\r\n| Token | Default |\r\n| -------------------------------- | ----------- |\r\n| `--sg-scrollbar-bg` | `transparent` |\r\n| `--sg-scrollbar-border` | inherits `--sg-border` |\r\n| `--sg-scrollbar-thumb` | `rgba(148,163,184,.45)` |\r\n| `--sg-scrollbar-thumb-hover` | `rgba(148,163,184,.65)` |\r\n| `--sg-scrollbar-thumb-active` | `rgba(148,163,184,.85)` |\r\n| `--sg-scrollbar-arrow` | inherits `--sg-muted` |\r\n| `--sg-scrollbar-arrow-hover` | inherits `--sg-fg` |\r\n| `--sg-scrollbar-arrow-hover-bg` | inherits `--sg-row-hover-bg` |\r\n| `--sg-scrollbar-arrow-active` | inherits `--sg-accent` |\r\n| `--sg-scrollbar-arrow-active-bg` | inherits `--sg-row-hover-bg` |\r\n| `--sg-scrollbar-arrow-disabled` | `rgba(148,163,184,.35)` |\r\n\r\n## Pill / status tokens (gallery-defined)\r\n\r\nThese are used by several demos but live in the gallery, not the\r\nlibrary. Copy if you want the same convention; rename freely otherwise.\r\n\r\n| Token | Used for |\r\n| --------------------------- | --------------------------------------- |\r\n| `--sg-pill-active` | \"Active\" status chip background |\r\n| `--sg-pill-active-fg` | \"Active\" status chip text |\r\n| `--sg-pill-pending` | \"Pending\" status chip background |\r\n| `--sg-pill-pending-fg` | \"Pending\" status chip text |\r\n| `--sg-pill-inactive` | \"Inactive\" status chip background |\r\n| `--sg-pill-inactive-fg` | \"Inactive\" status chip text |\r\n\r\n## How to override\r\n\r\n### Globally (your app's stylesheet)\r\n\r\n```css\r\n:root {\r\n --sg-accent: #db2777; /* hot pink everywhere */\r\n --sg-row-height: 36px;\r\n --sg-font: 'Inter', sans-serif;\r\n}\r\n```\r\n\r\n### Per-grid (Tailwind / inline style)\r\n\r\n```svelte\r\n<div class=\"my-grid\" style=\"--sg-accent: #16a34a\">\r\n <SvGrid {data} {columns} features={features} />\r\n</div>\r\n```\r\n\r\n### Dark mode\r\n\r\nThe library doesn't ship a built-in dark theme - it reads whatever\r\nyour app provides. Most apps gate via `[data-theme=\"dark\"]` on\r\n`<html>`; sv-grid's only requirement is that you override the same\r\ntoken set under that selector:\r\n\r\n```css\r\n[data-theme=\"dark\"] {\r\n --sg-bg: #0f172a;\r\n --sg-fg: #f1f5f9;\r\n --sg-border: #1e293b;\r\n --sg-header-bg: #1e293b;\r\n --sg-row-hover-bg: rgba(148, 163, 184, 0.14);\r\n --sg-selection-bg: rgba(99, 102, 241, 0.18);\r\n}\r\n```\r\n\r\nTheme presets ship in\r\n[demo 74](https://svgrid.com/demos/74-theme-integrations/) for Ant\r\nDesign, MUI, Fluent, Base Web, and shadcn - each with a light AND a\r\ndark token bundle ready to copy.\r\n\r\n### Built-in design-system presets\r\n\r\n`@svgrid/grid` also ships 20 ready-made presets as plain stylesheets - import the\r\none you want and the grid re-skins immediately, light and dark both included\r\n(toggle with the same `[data-theme=\"dark\"]` attribute as above):\r\n\r\n```css\r\n@import '@svgrid/grid/themes/material.css';\r\n```\r\n\r\nAvailable ids: `ember`, `shadcn`, `tailwind`, `material`, `excel`, `fluent`,\r\n`carbon`, `sap`, `salesforce`, `atlassian`, `github`, `antd`, `ag-alpine`,\r\n`bootstrap`, `vercel`, `linear`, `notion`, `nord`, `dracula`, `catppuccin`.\r\n\r\n`ember` is SvGrid's own theme rather than a copy of an external design system:\r\nwarm neutrals with Svelte orange as the single accent. It is the palette\r\nsvgrid.com runs on, so one import reproduces the look of the grids on the site.\r\n\r\n## Stability promise\r\n\r\nCore surface tokens (the first table) are **stable**: they will not\r\nbe renamed or removed in a minor version. New tokens may be added.\r\nSee [API stability](./api-stability.md) for the full policy.\r\n\r\nLayout / pinned / scrollbar / input tokens follow the same promise.\r\n\r\nPill tokens are gallery-defined - the LIBRARY makes no promise about\r\nthem.\r\n\r\n## See also\r\n\r\n- [Tailwind integration](./tailwind.md) - how to wire tokens through\r\n Tailwind's theming layer\r\n- [Custom cells + themes](https://svgrid.com/demos/10-custom-cells-and-themes/) demo - the canonical token-override example\r\n- [Theme integrations](https://svgrid.com/demos/74-theme-integrations/) demo - five design-system presets side by side\r\n"
3869
+ "markdown": "# Design tokens\r\n\r\nEvery visual property the grid exposes for theming is a CSS custom\r\nproperty (`--sg-*`). Tokens cascade like any other CSS variable -\r\noverride at `:root`, on a wrapper element, or per-grid; the closest\r\ndeclaration wins.\r\n<div data-docs-demo=\"74-theme-integrations\" data-height=\"540\"></div>\r\n\r\n> No build step. No theme provider. No design-system lock-in. Set a\r\n> few `--sg-*` tokens and the grid re-skins immediately.\r\n\r\nLive in [demo 74 (Theme integrations)](https://svgrid.com/demos/74-theme-integrations/)\r\n- shows the same grid rendered through Ant Design, MUI, Fluent, Base\r\nWeb, and shadcn token sets.\r\n\r\n## Core surface tokens\r\n\r\nThese are the tokens 95% of integrations override. Stable; safe to\r\nship in a corporate design system.\r\n\r\n| Token | Default (light) | Default (dark) | What it paints |\r\n| ---------------------- | ---------------------- | ---------------------- | -------------------------------------------------------------------- |\r\n| `--sg-bg` | `#ffffff` | `#0f172a` | Grid background / cell fill |\r\n| `--sg-fg` | `#0f172a` | `#f1f5f9` | Primary text colour |\r\n| `--sg-muted` | `#64748b` | `#94a3b8` | Secondary text (header subtitles, placeholders, \"no data\" message) |\r\n| `--sg-border` | `#e2e8f0` | `#1e293b` | Cell borders, separators |\r\n| `--sg-header-bg` | `#f8fafc` | `#1e293b` | Column-header row background |\r\n| `--sg-header-fg` | inherits `--sg-fg` | inherits `--sg-fg` | Column-header text |\r\n| `--sg-header-border` | inherits `--sg-border` | inherits `--sg-border` | Line under the column-header row only (body cell borders unaffected) |\r\n| `--sg-row-alt-bg` | inherits `--sg-bg` | inherits `--sg-bg` | Zebra-stripe background for even-indexed rows |\r\n| `--sg-row-hover-bg` | `rgba(148,163,184,.10)`| `rgba(148,163,184,.14)`| Hover row background |\r\n| `--sg-selection-bg` | `rgba(99,102,241,.10)` | `rgba(99,102,241,.18)` | Selected row / selected cell-range fill |\r\n| `--sg-accent` | `#6366f1` | `#818cf8` | Active-cell ring, primary buttons, sort indicator |\r\n| `--sg-focus-ring` | `0 0 0 2px rgba(99,102,241,.40)` | same | Box-shadow used for the focus outline |\r\n\r\n## Layout tokens\r\n\r\n| Token | Default | What it controls |\r\n| ---------------------- | ----------- | ---------------------------------------------------------------------- |\r\n| `--sg-radius` | `6px` | Border radius on rounded UI inside the grid (chips, badges, menus) |\r\n| `--sg-font` | inherits | Font family used inside the grid |\r\n\r\nRow height is **not** a token. The virtualizer needs the height as a\r\nnumber before it can lay rows out, so it comes from the `rowHeight`\r\nprop (default `30`) and is written as an inline style on each row. See\r\n[Row height](./rows/row-height.md).\r\n\r\n## Header typography tokens\r\n\r\nThe column-header label is themeable on its own so a preset can match\r\nits design system's grid header (bold, muted, small caps) without\r\ntouching the rest of the grid.\r\n\r\n| Token | Default | What it controls |\r\n| ------------------------- | ---------------------- | ------------------------------------------------------------------- |\r\n| `--sg-header-label-color` | inherits `--sg-header-fg` | Header label colour, when it should differ from the header text |\r\n| `--sg-header-weight` | `600` | Header label font weight |\r\n| `--sg-header-size` | inherits | Header label font size |\r\n| `--sg-header-transform` | `none` | Header label `text-transform` (e.g. `uppercase`) |\r\n| `--sg-header-tracking` | `normal` | Header label `letter-spacing` |\r\n| `--sg-header-min-height` | `auto` | Minimum height of a header cell |\r\n\r\n## Pinned-column tokens\r\n\r\n| Token | Default | What it paints |\r\n| ---------------------- | ----------- | ---------------------------------------------------------------------- |\r\n| `--sg-pinned-bg` | inherits `--sg-bg` | Pinned-column cell background (must be opaque to cover scroll under) |\r\n| `--sg-pinned-header-bg`| inherits `--sg-header-bg` | Pinned-column header background |\r\n| `--sg-pinned-divider` | `#cbd5e1` | 2px separator between pinned and scrolling region |\r\n| `--sg-pinned-border` | `color-mix(in oklab, var(--sg-accent) 24%, transparent)` | Top/bottom border of pinned **rows** |\r\n| `--sg-pinned-shadow-color` | `rgba(15,23,42,.22)` | Colour of the shadow a pinned column casts into the scroll area |\r\n\r\n## Surface tokens\r\n\r\n| Token | Default | What it paints |\r\n| ---------------------- | ------------------------- | ------------------------------------------------------------------ |\r\n| `--sg-bg-subtle` | inherits `--sg-header-bg` | Inset surfaces such as the master-detail region behind a nested grid |\r\n\r\n## Input + form tokens\r\n\r\n| Token | Default | What it paints |\r\n| ---------------------- | ---------------------- | -------------------------------------------------------------------- |\r\n| `--sg-input-bg` | inherits `--sg-bg` | Cell-editor `<input>` background |\r\n| `--sg-input-border` | inherits `--sg-border` | Cell-editor border |\r\n| `--sg-rating-on` | `#f59e0b` | Filled star colour in the rating editor |\r\n| `--sg-rating-empty` | `#cbd5e1` | Empty star colour |\r\n| `--sg-rating-hover` | `#fbbf24` | Star hover-state colour |\r\n\r\n## Scrollbar tokens\r\n\r\nThe grid uses a custom scrollbar so it looks consistent across OSes.\r\n\r\n| Token | Default |\r\n| -------------------------------- | ----------- |\r\n| `--sg-scrollbar-bg` | `transparent` |\r\n| `--sg-scrollbar-border` | inherits `--sg-border` |\r\n| `--sg-scrollbar-thumb` | `rgba(148,163,184,.45)` |\r\n| `--sg-scrollbar-thumb-hover` | `rgba(148,163,184,.65)` |\r\n| `--sg-scrollbar-thumb-active` | `rgba(148,163,184,.85)` |\r\n| `--sg-scrollbar-arrow` | inherits `--sg-muted` |\r\n| `--sg-scrollbar-arrow-hover` | inherits `--sg-fg` |\r\n| `--sg-scrollbar-arrow-hover-bg` | inherits `--sg-row-hover-bg` |\r\n| `--sg-scrollbar-arrow-active` | inherits `--sg-accent` |\r\n| `--sg-scrollbar-arrow-active-bg` | inherits `--sg-row-hover-bg` |\r\n| `--sg-scrollbar-arrow-disabled` | `rgba(148,163,184,.35)` |\r\n\r\n## Pill / status tokens (gallery-defined)\r\n\r\nThese are used by several demos but live in the gallery, not the\r\nlibrary. Copy if you want the same convention; rename freely otherwise.\r\n\r\n| Token | Used for |\r\n| --------------------------- | --------------------------------------- |\r\n| `--sg-pill-active` | \"Active\" status chip background |\r\n| `--sg-pill-active-fg` | \"Active\" status chip text |\r\n| `--sg-pill-pending` | \"Pending\" status chip background |\r\n| `--sg-pill-pending-fg` | \"Pending\" status chip text |\r\n| `--sg-pill-inactive` | \"Inactive\" status chip background |\r\n| `--sg-pill-inactive-fg` | \"Inactive\" status chip text |\r\n\r\n## How to override\r\n\r\n### Globally (your app's stylesheet)\r\n\r\n```css\r\n:root {\r\n --sg-accent: #db2777; /* hot pink everywhere */\r\n --sg-radius: 10px;\r\n --sg-font: 'Inter', sans-serif;\r\n}\r\n```\r\n\r\n### Per-grid (Tailwind / inline style)\r\n\r\n```svelte\r\n<div class=\"my-grid\" style=\"--sg-accent: #16a34a\">\r\n <SvGrid {data} {columns} features={features} />\r\n</div>\r\n```\r\n\r\n### Dark mode\r\n\r\nThe library doesn't ship a built-in dark theme - it reads whatever\r\nyour app provides. Most apps gate via `[data-theme=\"dark\"]` on\r\n`<html>`; sv-grid's only requirement is that you override the same\r\ntoken set under that selector:\r\n\r\n```css\r\n[data-theme=\"dark\"] {\r\n --sg-bg: #0f172a;\r\n --sg-fg: #f1f5f9;\r\n --sg-border: #1e293b;\r\n --sg-header-bg: #1e293b;\r\n --sg-row-hover-bg: rgba(148, 163, 184, 0.14);\r\n --sg-selection-bg: rgba(99, 102, 241, 0.18);\r\n}\r\n```\r\n\r\nTheme presets ship in\r\n[demo 74](https://svgrid.com/demos/74-theme-integrations/) for Ant\r\nDesign, MUI, Fluent, Base Web, and shadcn - each with a light AND a\r\ndark token bundle ready to copy.\r\n\r\n### Built-in design-system presets\r\n\r\n`@svgrid/grid` also ships 20 ready-made presets as plain stylesheets - import the\r\none you want and the grid re-skins immediately, light and dark both included\r\n(toggle with the same `[data-theme=\"dark\"]` attribute as above):\r\n\r\n```css\r\n@import '@svgrid/grid/themes/material.css';\r\n```\r\n\r\nAvailable ids: `ember`, `shadcn`, `tailwind`, `material`, `excel`, `fluent`,\r\n`carbon`, `sap`, `salesforce`, `atlassian`, `github`, `antd`, `ag-alpine`,\r\n`bootstrap`, `vercel`, `linear`, `notion`, `nord`, `dracula`, `catppuccin`.\r\n\r\n`ember` is SvGrid's own theme rather than a copy of an external design system:\r\nwarm neutrals with Svelte orange as the single accent. It is the palette\r\nsvgrid.com runs on, so one import reproduces the look of the grids on the site.\r\n\r\n## Stability promise\r\n\r\nCore surface tokens (the first table) are **stable**: they will not\r\nbe renamed or removed in a minor version. New tokens may be added.\r\nSee [API stability](./api-stability.md) for the full policy.\r\n\r\nLayout / pinned / scrollbar / input tokens follow the same promise.\r\n\r\nPill tokens are gallery-defined - the LIBRARY makes no promise about\r\nthem.\r\n\r\n## See also\r\n\r\n- [Tailwind integration](./tailwind.md) - how to wire tokens through\r\n Tailwind's theming layer\r\n- [Custom cells + themes](https://svgrid.com/demos/10-custom-cells-and-themes/) demo - the canonical token-override example\r\n- [Theme integrations](https://svgrid.com/demos/74-theme-integrations/) demo - five design-system presets side by side\r\n"
3852
3870
  },
3853
3871
  {
3854
3872
  "slug": "help/ui-components/buttons",
@@ -4718,7 +4736,7 @@ export const docs = [
4718
4736
  "slug": "reference/SvGrid",
4719
4737
  "path": "docs/reference/SvGrid.md",
4720
4738
  "title": "`<SvGrid>` reference",
4721
- "markdown": "# `<SvGrid>` reference\n\nThe render component. One `<SvGrid>` element per grid instance.\n\n```svelte\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n showPagination={true}\n pageSize={25}\n onApiReady={(api) => (gridApi = api)}\n/>\n```\n\n## Required props\n\n| Prop | Type | Notes |\n| ---------- | ------------------------------------------ | ---------------------------------------------------------------------- |\n| `data` | `ReadonlyArray<TData>` | The row source. Re-rendering on `data` reference change is automatic. |\n| `columns` | `Array<ColumnDef<TFeatures, TData>>` | See [ColumnDef reference](./ColumnDef.md). |\n| `features` | `TFeatures` | Build with `tableFeatures({...})`. See [features reference](./features.md). |\n\n## Data state\n\n| Prop | Type | Default | Notes |\n| -------------- | --------------------- | -------------------- | ----------------------------------------------------------- |\n| `loading` | `boolean` | `false` | Shows a built-in loading overlay. |\n| `error` | `string \\| null` | `null` | Shows an error banner inside the shell. |\n| `emptyMessage` | `string` | `\"No data\"` | Replaces the empty-state copy. |\n\n## Layout\n\n| Prop | Type | Default | Notes |\n| -------------------- | -------------------- | ----------- | ---------------------------------------------------------------------- |\n| `containerHeight` | `number \\| string` | `520` | Pixels (number) or any CSS height (string). Use `'100%'` inside a flex parent. |\n| `rowHeight` | `number` | `36` | In pixels. Drives virtualizer math when virtualization is on. |\n| `columnWidth` | `number` | `140` | Default for columns without an explicit `width`. |\n| `fitColumns` | `boolean` | `false` | Scale columns proportionally to fill the viewport; residue absorbed in the last column. Shrinks down to 85% of natural widths; beyond that the user gets a horizontal scrollbar. |\n| `showRowNumbers` | `boolean` | `false` | Leading 1-based row-number column. |\n| `rowNumberWidth` | `number` | `56` | Width (px) of the row-number column. Default fits up to \"99,999\"; bump it for six-figure row counts so the largest number stays fully visible at the bottom of a long scroll. |\n| `initialColumnPinning` | `{ left?, right? }`| - | Seed left/right pinning at mount. |\n\n## Virtualization\n\n| Prop | Type | Default | Notes |\n| ---------------------- | --------- | ------- | -------------------------------------------------- |\n| `virtualization` | `boolean` | `true` | Row virtualization. |\n| `columnVirtualization` | `boolean` | `true` | Column virtualization. Disable for sticky-column pinning. |\n| `overscan` | `number` | `8` | Rows kept rendered above + below the viewport. |\n| `columnOverscan` | `number` | `3` | Columns kept rendered left + right of the viewport. |\n\n## Filter UI\n\n| Prop | Type | Default | Notes |\n| ------------------- | ------------------------------------------ | --------- | ---------------------------------------------------------------------- |\n| `filterMode` | `'menu' \\| 'row' \\| 'global' \\| 'none'` | `'menu'` | Umbrella prop. Overridden per-surface by the three below. |\n| `showGlobalFilter` | `boolean` | derived | Show the global search input. |\n| `showColumnFilters` | `boolean` | derived | Show the column-menu filter funnel. |\n| `showFilterRow` | `boolean` | derived | Show the per-column filter row under the header. |\n| `showFilterMenu` | `boolean` | derived | (Legacy alias for `showColumnFilters`.) |\n| `externalFilter` | `boolean` | `false` | Grid records filter UI state but does NOT filter rows. Pair with `onFiltersChange`. |\n\n## Selection\n\n| Prop | Type | Default | Notes |\n| -------------------- | ------------------------------------------ | ------- | ---------------------------------------------------- |\n| `selectionMode` | `'both' \\| 'row' \\| 'cell' \\| 'none'` | `'both'`| Umbrella prop. Overridden per-surface by the two below. |\n| `showRowSelection` | `boolean` | derived | Checkbox column. |\n| `enableCellSelection`| `boolean` | derived | Click-and-drag range selection. |\n\n## Editing\n\n| Prop | Type | Default | Notes |\n| --------------------- | --------- | ------- | ------------------------------------------------------ |\n| `enableInlineEditing` | `boolean` | `false` | Per-column `editorType` still required for a column to be editable. |\n| `enableRowSummaries` | `boolean` | `false` | Footer row with sum/avg/count summaries. |\n\n## Sort\n\n| Prop | Type | Default | Notes |\n| -------------- | --------- | ------- | -------------------------------------------------------------- |\n| `externalSort` | `boolean` | `false` | Grid records sort state but does NOT re-order rows. Pair with `onSortingChange`. |\n\n## Pagination\n\n| Prop | Type | Default | Notes |\n| ---------------- | --------- | ------- | -------------------------------------------------------------- |\n| `showPagination` | `boolean` | `false` | Show the footer pager. |\n| `pageSize` | `number` | `10` | Initial page size. |\n\n## Grouping\n\n| Prop | Type | Default | Notes |\n| ---------------------- | --------- | ------- | -------------------------------- |\n| `showGroupingControls` | `boolean` | `false` | Show the group-by toolbar. |\n\n## Callbacks\n\nAll callbacks are optional. None of them are required for the grid to\nfunction - they exist for parents that want to observe or override.\n\n### `onApiReady(api)`\n\nFires once after mount with the [`SvGridApi`](./SvGridApi.md) for\nimperative operations.\n\n### `onSortingChange(sorting)`\n\n```ts\nonSortingChange?: (sorting: Array<{ id: string; desc: boolean }>) => void\n```\n\nFires when the user clicks a sort header. With `externalSort={true}`,\nthis is your hook to re-fetch / re-order the source data.\n\n### `onFiltersChange(filters)`\n\n```ts\nonFiltersChange?: (filters: {\n global: string\n columns: Array<{\n id: string\n operator: 'contains' | 'equals' | 'startsWith' | 'greaterThan' | 'lessThan' | 'isBlank'\n value: string\n selectedValues?: Array<string>\n }>\n}) => void\n```\n\nFires when the global filter, any column operator filter, or any facet\nchecklist changes. With `externalFilter={true}`, this is your hook to\nre-fetch.\n\n### `onRowSelectionChange(selection, rows)`\n\n```ts\nonRowSelectionChange?: (\n selection: Record<string, boolean>,\n rows: TData[],\n) => void\n```\n\n`selection` is keyed by row index; `rows` is the materialised array of\nselected rows.\n\n### `onCellValueChange(event)`\n\n```ts\nonCellValueChange?: (event: {\n rowIndex: number\n columnId: string\n oldValue: unknown\n newValue: unknown\n row: TData\n}) => void\n```\n\nFires after the grid has written the parsed value back into the row.\nUse this for cascading recomputes (line totals, derived columns) and\nfor sending edits to a server. See [Saving values](../help/editing/saving-values.md).\n\n### `onActiveCellChange(cell)`\n\n```ts\nonActiveCellChange?: (cell: {\n rowIndex: number\n colIndex: number\n columnId: string\n}) => void\n```\n\nFires every time the active cell changes - click, keyboard move,\nTab, Page Up/Down. Toolbar / ribbon UIs use this to stay synced with\nthe grid's selection without polling.\n\n## See also\n\n- [`SvGridApi`](./SvGridApi.md) - the imperative API exposed via `onApiReady`\n- [`ColumnDef`](./ColumnDef.md) - column-definition shape\n- [features](./features.md) - the feature registry\n- [Enterprise extensions](./enterprise.md) - what `installEnterprise(api)` adds\n"
4739
+ "markdown": "# `<SvGrid>` reference\n\nThe render component. One `<SvGrid>` element per grid instance.\n\n```svelte\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n showPagination={true}\n pageSize={25}\n onApiReady={(api) => (gridApi = api)}\n/>\n```\n\n## Required props\n\n| Prop | Type | Notes |\n| ---------- | ------------------------------------------ | ---------------------------------------------------------------------- |\n| `data` | `ReadonlyArray<TData>` | The row source. Re-rendering on `data` reference change is automatic. |\n| `columns` | `Array<ColumnDef<TFeatures, TData>>` | See [ColumnDef reference](./ColumnDef.md). |\n| `features` | `TFeatures` | Build with `tableFeatures({...})`. See [features reference](./features.md). |\n\n## Data state\n\n| Prop | Type | Default | Notes |\n| -------------- | --------------------- | -------------------- | ----------------------------------------------------------- |\n| `loading` | `boolean` | `false` | Shows a built-in loading overlay. |\n| `error` | `string \\| null` | `null` | Shows an error banner inside the shell. |\n| `emptyMessage` | `string` | `\"No data\"` | Replaces the empty-state copy. |\n\n## Layout\n\n| Prop | Type | Default | Notes |\n| -------------------- | -------------------- | ----------- | ---------------------------------------------------------------------- |\n| `containerHeight` | `number \\| string` | `520` | Pixels (number) or any CSS height (string). Use `'100%'` inside a flex parent. |\n| `rowHeight` | `number \\| (i) => number` | `30` | In pixels. Drives virtualizer math when virtualization is on. Pass a function for per-row heights. Not a CSS token - the virtualizer needs a number, so it is written as an inline style per row. |\n| `columnWidth` | `number` | `140` | Default for columns without an explicit `width`. |\n| `fitColumns` | `boolean` | `false` | Scale columns proportionally to fill the viewport; residue absorbed in the last column. Shrinks down to 85% of natural widths; beyond that the user gets a horizontal scrollbar. |\n| `showRowNumbers` | `boolean` | `false` | Leading 1-based row-number column. |\n| `rowNumberWidth` | `number` | `56` | Width (px) of the row-number column. Default fits up to \"99,999\"; bump it for six-figure row counts so the largest number stays fully visible at the bottom of a long scroll. |\n| `initialColumnPinning` | `{ left?, right? }`| - | Seed left/right pinning at mount. |\n\n## Virtualization\n\n| Prop | Type | Default | Notes |\n| ---------------------- | --------- | ------- | -------------------------------------------------- |\n| `virtualization` | `boolean` | `true` | Row virtualization. |\n| `columnVirtualization` | `boolean` | `true` | Column virtualization. Disable for sticky-column pinning. |\n| `overscan` | `number` | `8` | Rows kept rendered above + below the viewport. |\n| `columnOverscan` | `number` | `3` | Columns kept rendered left + right of the viewport. |\n\n## Filter UI\n\n| Prop | Type | Default | Notes |\n| ------------------- | ------------------------------------------ | --------- | ---------------------------------------------------------------------- |\n| `filterMode` | `'menu' \\| 'row' \\| 'global' \\| 'none'` | `'menu'` | Umbrella prop. Overridden per-surface by the three below. |\n| `showGlobalFilter` | `boolean` | derived | Show the global search input. |\n| `showColumnFilters` | `boolean` | derived | Show the column-menu filter funnel. |\n| `showFilterRow` | `boolean` | derived | Show the per-column filter row under the header. |\n| `showFilterMenu` | `boolean` | derived | (Legacy alias for `showColumnFilters`.) |\n| `externalFilter` | `boolean` | `false` | Grid records filter UI state but does NOT filter rows. Pair with `onFiltersChange`. |\n\n## Selection\n\n| Prop | Type | Default | Notes |\n| -------------------- | ------------------------------------------ | ------- | ---------------------------------------------------- |\n| `selectionMode` | `'both' \\| 'row' \\| 'cell' \\| 'none'` | `'both'`| Umbrella prop. Overridden per-surface by the two below. |\n| `showRowSelection` | `boolean` | derived | Checkbox column. |\n| `enableCellSelection`| `boolean` | derived | Click-and-drag range selection. |\n\n## Editing\n\n| Prop | Type | Default | Notes |\n| --------------------- | --------- | ------- | ------------------------------------------------------ |\n| `enableInlineEditing` | `boolean` | `false` | Per-column `editorType` still required for a column to be editable. |\n| `enableRowSummaries` | `boolean` | `false` | Footer row with sum/avg/count summaries. |\n\n## Sort\n\n| Prop | Type | Default | Notes |\n| -------------- | --------- | ------- | -------------------------------------------------------------- |\n| `externalSort` | `boolean` | `false` | Grid records sort state but does NOT re-order rows. Pair with `onSortingChange`. |\n\n## Pagination\n\n| Prop | Type | Default | Notes |\n| ---------------- | --------- | ------- | -------------------------------------------------------------- |\n| `showPagination` | `boolean` | `false` | Show the footer pager. |\n| `pageSize` | `number` | `10` | Initial page size. |\n\n## Grouping\n\n| Prop | Type | Default | Notes |\n| ---------------------- | -------------------------- | ------- | -------------------------------- |\n| `showGroupingControls` | `boolean` | `false` | Show the group-by toolbar. |\n| `groupBy` | `ReadonlyArray<string>` | `[]` | Column ids to group by, outermost first. Re-applies when the prop changes; ignored when `treeData` is set. |\n| `expanded` | `Record<string, boolean>` | `{}` | Which group / tree rows are open, keyed by row id. Seeds the state and re-applies on change. |\n\n## Callbacks\n\nAll callbacks are optional. None of them are required for the grid to\nfunction - they exist for parents that want to observe or override.\n\n### `onApiReady(api)`\n\nFires once after mount with the [`SvGridApi`](./SvGridApi.md) for\nimperative operations.\n\n### `onSortingChange(sorting)`\n\n```ts\nonSortingChange?: (sorting: Array<{ id: string; desc: boolean }>) => void\n```\n\nFires when the user clicks a sort header. With `externalSort={true}`,\nthis is your hook to re-fetch / re-order the source data.\n\n### `onFiltersChange(filters)`\n\n```ts\nonFiltersChange?: (filters: {\n global: string\n columns: Array<{\n id: string\n operator: 'contains' | 'equals' | 'startsWith' | 'greaterThan' | 'lessThan' | 'isBlank'\n value: string\n selectedValues?: Array<string>\n }>\n}) => void\n```\n\nFires when the global filter, any column operator filter, or any facet\nchecklist changes. With `externalFilter={true}`, this is your hook to\nre-fetch.\n\n### `onRowSelectionChange(selection, rows)`\n\n```ts\nonRowSelectionChange?: (\n selection: Record<string, boolean>,\n rows: TData[],\n) => void\n```\n\n`selection` is keyed by row index; `rows` is the materialised array of\nselected rows.\n\n### `onCellValueChange(event)`\n\n```ts\nonCellValueChange?: (event: {\n rowIndex: number\n columnId: string\n oldValue: unknown\n newValue: unknown\n row: TData\n}) => void\n```\n\nFires when an inline edit **commits**. This is the editor's commit path\nonly - the programmatic `api.setCellValue()` writes the value without\nfiring it. Use this for cascading recomputes (line totals, derived\ncolumns) and for sending edits to a server. See\n[Saving values](../help/editing/saving-values.md).\n\n### `onExpandedChange(expanded)`\n\n```ts\nonExpandedChange?: (expanded: Record<string, boolean>) => void\n```\n\nFires whenever the expanded set changes - a click on a group banner,\n`api.setRowExpanded()`, `api.expandAllGroups()` /\n`api.collapseAllGroups()`. Receives the full next map, so it can be\nwritten straight back into the `expanded` prop for a controlled setup\nwithout looping. Group row ids look like\n`group_department_Engineering`; tree rows use the engine row id.\n\n### `onActiveCellChange(cell)`\n\n```ts\nonActiveCellChange?: (cell: {\n rowIndex: number\n colIndex: number\n columnId: string\n}) => void\n```\n\nFires every time the active cell changes - click, keyboard move,\nTab, Page Up/Down. Toolbar / ribbon UIs use this to stay synced with\nthe grid's selection without polling.\n\n## See also\n\n- [`SvGridApi`](./SvGridApi.md) - the imperative API exposed via `onApiReady`\n- [`ColumnDef`](./ColumnDef.md) - column-definition shape\n- [features](./features.md) - the feature registry\n- [Enterprise extensions](./enterprise.md) - what `installEnterprise(api)` adds\n"
4722
4740
  },
4723
4741
  {
4724
4742
  "slug": "reference/SvGridApi",
@@ -4778,7 +4796,7 @@ export const docs = [
4778
4796
  "slug": "reference/bundle-size",
4779
4797
  "path": "docs/reference/bundle-size.md",
4780
4798
  "title": "Bundle size",
4781
- "markdown": "# Bundle size\r\n\r\nWhat SvGrid costs in your bundle, how to reproduce the number on your\r\nbranch, and what to do if size matters.\r\n\r\n## Measured\r\n\r\nRe-measured **2026-08-20** with the script that ships in the repo:\r\n\r\n```bash\r\nnode packages/grid/scripts/measure-size.mjs\r\n```\r\n\r\n| Target | Base JS (gzip) | CSS (gzip) | Loaded on demand |\r\n| --- | ---: | ---: | ---: |\r\n| Headless core (`createGrid`) | **2.3 kB** | - | - |\r\n| Full render component (`<SvGrid>`) | **78.2 kB** | **9.0 kB** | 77.1 kB |\r\n\r\nSvelte is a peer dependency and is excluded from every figure. Builds are\r\nminified and gzipped at level 9.\r\n\r\nThe \"loaded on demand\" column is code that is reachable only through\r\n`import()`, so it never lands in your initial bundle. As measured, that\r\nsplits into:\r\n\r\n| Chunk | gzip | Loads when |\r\n| --- | ---: | --- |\r\n| `SvDateTimePicker` | 18.2 kB | a date / datetime / time cell editor opens |\r\n| `SvGridChart` | 15.7 kB | a chart renders |\r\n| `chart` (engine) | 11.7 kB | charting is enabled |\r\n| `GridMenus` | 11.7 kB | a header or context menu opens |\r\n| `SvGridChartPanel` | 7.5 kB | the chart panel opens |\r\n| `SvGridDropdown` | 5.2 kB | a list / chips cell editor or the page-size picker opens |\r\n| `dismissable` | 3.7 kB | any popover, menu or dropdown layer opens |\r\n| `export-format` | 1.8 kB | CSV / TSV / JSON export or clipboard copy runs |\r\n| `popover` | 0.9 kB | a popover is positioned |\r\n| `SvGridChartView` | 0.7 kB | the grid switches to chart view |\r\n\r\nThe Kanban board and the scheduler / calendar view are not in either\r\nfigure: their renderers live in `@svgrid/enterprise` and register into the\r\nfree grid through the board and scheduler view seams.\r\n\r\n## Reproduce on your branch\r\n\r\n`measure-size.mjs` runs two isolated Vite library builds, one per target,\r\nwith Svelte marked external, then gzips each emitted chunk and classifies\r\nit as `base` (statically reachable from the entry) or `lazy` (reachable\r\nonly via `import()`).\r\n\r\n```bash\r\nnode packages/grid/scripts/measure-size.mjs\r\n# or, from the repo root:\r\npnpm size\r\n```\r\n\r\nTo see where the weight sits inside the base bundle:\r\n\r\n```bash\r\ncorepack pnpm --filter @svgrid/grid build\r\nnpx source-map-explorer packages/grid/dist/index.js\r\n```\r\n\r\nA treemap opens in your browser. Each block is a source file scaled by its\r\nbyte cost in the final bundle.\r\n\r\n## @svgrid/enterprise\r\n\r\nThe Enterprise pack is a separate install and a separate bundle. `xlsx`\r\nexport pulls JSZip and PDF export pulls pdfmake as optional peer\r\ndependencies, imported on the first `api.exportData(...)` call rather than\r\nat module load, so neither is in your synchronous bundle.\r\n\r\n## What to do if size matters\r\n\r\n1. **Use the headless engine for read-only views.** When you only need to\r\n display server-side data with no interaction, `createGrid` plus a short\r\n `<table>` renderer is 2.3 kB instead of 78.2 kB. See the\r\n [headless engine reference](./headless-engine.md).\r\n2. **Register only the features you use.** The grid is feature-gated:\r\n sorting, filtering, grouping, pagination, expansion, and selection are\r\n each opt-in and tree-shake out when not imported. See the\r\n [features reference](./features.md).\r\n3. **Let the lazy chunks stay lazy.** Charts, date/time editors, menus,\r\n and export already split themselves. Importing their modules directly\r\n at the top level pulls them back into your base bundle.\r\n4. **Code-split the Enterprise pack.** `installEnterprise(api)` is\r\n async-safe, so import it in the route that needs export rather than at\r\n module load:\r\n `const { installEnterprise } = await import('@svgrid/enterprise')`.\r\n\r\n## See also\r\n\r\n- [Features reference](./features.md) - what each feature does\r\n- [Headless engine reference](./headless-engine.md) - skip the renderer entirely\r\n- [Going to production guide](../getting-started/6-going-to-production.md)\r\n"
4799
+ "markdown": "# Bundle size\r\n\r\nWhat SvGrid costs in your bundle, how to reproduce the number on your\r\nbranch, and what to do if size matters.\r\n\r\n## Measured\r\n\r\nRe-measured **2026-08-20** with the script that ships in the repo:\r\n\r\n```bash\r\nnode packages/grid/scripts/measure-size.mjs\r\n```\r\n\r\n| Target | Base JS (gzip) | CSS (gzip) | Loaded on demand |\r\n| --- | ---: | ---: | ---: |\r\n| Headless core (`createGrid`) | **2.3 kB** | - | - |\r\n| Headless subpath (`@svgrid/grid/core`) | **4.2 kB** | - | - |\r\n| Full render component (`<SvGrid>`) | **77.3 kB** | **9.1 kB** | 81.4 kB |\r\n\r\nThe two headless rows measure different things on purpose. `createGrid` is the\r\nengine plus the row model it needs, which is what you pay when you import just\r\nthat symbol. The subpath row is every export on `@svgrid/grid/core` pulled in at\r\nonce with nothing tree-shaken, so it is the ceiling rather than the typical\r\ncost - a real consumer importing `createSvGrid` and two row models sits near the\r\nlower number.\r\n\r\nSvelte is a peer dependency and is excluded from every figure. Builds are\r\nminified and gzipped at level 9.\r\n\r\nThe \"loaded on demand\" column is code that is reachable only through\r\n`import()`, so it never lands in your initial bundle. As measured, that\r\nsplits into:\r\n\r\n| Chunk | gzip | Loads when |\r\n| --- | ---: | --- |\r\n| `SvDateTimePicker` | 18.2 kB | a date / datetime / time cell editor opens |\r\n| `SvGridChart` | 15.7 kB | a chart renders |\r\n| `chart` (engine) | 11.7 kB | charting is enabled |\r\n| `GridMenus` | 11.7 kB | a header or context menu opens |\r\n| `SvGridChartPanel` | 7.5 kB | the chart panel opens |\r\n| `SvGridCellEditor` | 4.3 kB | editing is enabled (loads at mount, before the first edit) |\r\n| `SvGridDropdown` | 5.2 kB | a list / chips cell editor or the page-size picker opens |\r\n| `dismissable` | 3.7 kB | any popover, menu or dropdown layer opens |\r\n| `export-format` | 1.8 kB | CSV / TSV / JSON export or clipboard copy runs |\r\n| `popover` | 0.9 kB | a popover is positioned |\r\n| `SvGridChartView` | 0.7 kB | the grid switches to chart view |\r\n\r\nThe Kanban board and the scheduler / calendar view are not in either\r\nfigure: their renderers live in `@svgrid/enterprise` and register into the\r\nfree grid through the board and scheduler view seams.\r\n\r\n## Reproduce on your branch\r\n\r\n`measure-size.mjs` runs two isolated Vite library builds, one per target,\r\nwith Svelte marked external, then gzips each emitted chunk and classifies\r\nit as `base` (statically reachable from the entry) or `lazy` (reachable\r\nonly via `import()`).\r\n\r\n```bash\r\nnode packages/grid/scripts/measure-size.mjs\r\n# or, from the repo root:\r\npnpm size\r\n```\r\n\r\nTo see where the weight sits inside the base bundle:\r\n\r\n```bash\r\ncorepack pnpm --filter @svgrid/grid build\r\nnpx source-map-explorer packages/grid/dist/index.js\r\n```\r\n\r\nA treemap opens in your browser. Each block is a source file scaled by its\r\nbyte cost in the final bundle.\r\n\r\n## @svgrid/enterprise\r\n\r\nThe Enterprise pack is a separate install and a separate bundle. `xlsx`\r\nexport pulls JSZip and PDF export pulls pdfmake as optional peer\r\ndependencies, imported on the first `api.exportData(...)` call rather than\r\nat module load, so neither is in your synchronous bundle.\r\n\r\n## What to do if size matters\r\n\r\n1. **Use the headless engine for read-only views.** When you only need to\r\n display server-side data with no interaction, `createGrid` plus a short\r\n `<table>` renderer is 2.3 kB instead of 78.2 kB. See the\r\n [headless engine reference](./headless-engine.md).\r\n2. **Register only the features you use.** The grid is feature-gated:\r\n sorting, filtering, grouping, pagination, expansion, and selection are\r\n each opt-in and tree-shake out when not imported. See the\r\n [features reference](./features.md).\r\n3. **Let the lazy chunks stay lazy.** Charts, date/time editors, menus,\r\n and export already split themselves. Importing their modules directly\r\n at the top level pulls them back into your base bundle.\r\n4. **Code-split the Enterprise pack.** `installEnterprise(api)` is\r\n async-safe, so import it in the route that needs export rather than at\r\n module load:\r\n `const { installEnterprise } = await import('@svgrid/enterprise')`.\r\n\r\n## See also\r\n\r\n- [Features reference](./features.md) - what each feature does\r\n- [Headless engine reference](./headless-engine.md) - skip the renderer entirely\r\n- [Going to production guide](../getting-started/6-going-to-production.md)\r\n"
4782
4800
  },
4783
4801
  {
4784
4802
  "slug": "reference/enterprise",
@@ -4808,7 +4826,7 @@ export const docs = [
4808
4826
  "slug": "why-headless",
4809
4827
  "path": "docs/why-headless.md",
4810
4828
  "title": "Why headless?",
4811
- "markdown": "# Why headless?\n\nSvGrid is **headless at the core**, with a fully-styled Svelte component\nshipped on top. That two-layer split is deliberate, and worth\nunderstanding before you reach for either.\n\n## What \"headless\" actually means here\n\nThe core - `createSvGrid` from `@svgrid/grid/core` - knows about\nrows, columns, sorting, filtering, grouping, pagination, expansion, and\nselection. It does **not** know about pixels, DOM, ARIA, or CSS. It is\na state machine over your data that you query and mutate from Svelte.\n\nThe component - `<SvGrid>` - is one (opinionated) way to render that\nstate machine into a `<table>`. It is itself written against the\nheadless core, so the same hooks are available to you if you want to\nwrite your own renderer.\n\n```text\n┌──────────────────────────────────────────────────────┐\n│ Your app │\n└───────────────┬──────────────────────────────────────┘\n │\n ▼\n ┌─────────────────────┐\n │ <SvGrid> (Svelte) │ ← default renderer, ARIA,\n │ FlexRender │ keyboard, drag handles,\n │ formatters, menus │ theme tokens\n └─────────┬───────────┘\n │\n ▼\n ┌─────────────────────┐\n │ createSvGrid() │ ← rows × cols × state\n │ row models │ sort / filter / page /\n │ features │ group / expand\n └─────────────────────┘\n```\n\n## What you get from headless\n\n**1. The renderer is replaceable.** Want a virtualised React grid? A\ncanvas-based renderer for 1 M rows? A read-only `<table>` for a printed\nreport? `createSvGrid` returns the same state machine for all of them.\nYou write the markup, you keep the headless brain.\n\n```ts\nimport { createSvGrid, createCoreRowModel, createSortedRowModel,\n tableFeatures, rowSortingFeature, sortFns } from '@svgrid/grid'\n\nconst grid = createSvGrid({\n _features: tableFeatures({ rowSortingFeature }),\n _rowModels: {\n coreRowModel: createCoreRowModel(),\n sortedRowModel: createSortedRowModel(sortFns),\n },\n columns,\n data,\n})\n\n// Your own render loop:\nfor (const row of grid.getRowModel().rows) {\n for (const cell of row.getAllCells()) drawCell(cell)\n}\n```\n\nHere is exactly that - the same headless brain, rendered as a plain\nhand-styled `<table>` instead of `<SvGrid>`. Sort and filter are the engine's;\nthe markup is the demo's:\n\n<div data-docs-demo=\"186-headless-table\" data-height=\"480\"></div>\n\n**2. Features are opt-in modules.** Monolithic grid libraries ship everything\nin one bundle. With SvGrid you only register what you use:\n\n```ts\nimport { tableFeatures, rowSortingFeature } from '@svgrid/grid'\n\n// no filtering, no grouping, no pagination - none of that code is\n// reachable from this grid instance\nconst features = tableFeatures({ rowSortingFeature })\n```\n\nThe features object is the contract the headless core checks for\noptional capabilities. Each feature ships a small chunk of state +\nhelpers; if it's not in `tableFeatures()`, the core never asks for it\nand Vite tree-shakes away the rest.\n\n**3. Tests are fast and DOM-free.** `createSvGrid` runs without a\nbrowser:\n\n```ts\nimport { createSvGrid, ... } from '@svgrid/grid'\n\ntest('sorts by salary descending', () => {\n const grid = createSvGrid({ ..., state: { sorting: [{ id: 'salary', desc: true }] } })\n const rows = grid.getRowModel().rows\n expect(rows[0]!.getValue('salary')).toBeGreaterThan(rows[1]!.getValue('salary'))\n})\n```\n\nNo JSDOM, no Playwright, no test renderer. The headless contract is the\nunit of test.\n\n**4. Server-side rendering is a non-feature.** Because the core has no\nDOM, you can call `grid.getRowModel().rows` inside a SvelteKit\n`+page.server.ts` and pre-bake the table HTML before it ever reaches\nthe browser. Demo [`19-ssr`](../examples/src/demos/19-ssr.svelte)\nwalks through that.\n\n**5. State is yours to own.** Sort clauses, filter predicates, expansion\nstate, selection state - all of it lives in a `store` you can serialise\nto a URL, sync to a query string, or restore from `localStorage`. The\ndefault `<SvGrid>` wires this up for you, but the wires are visible:\n\n```ts\n// Persist\nlocalStorage.setItem('grid', JSON.stringify(grid.getState()))\n\n// Restore\nconst saved = JSON.parse(localStorage.getItem('grid')!)\ngrid.store.setState((prev) => ({ ...prev, ...saved }))\n```\n\n## When the wrapper is the right tool anyway\n\nYou won't usually write a custom renderer. `<SvGrid>` is the default\nbecause the 80% case is \"I want a table, with sort and filter, that\nlooks correct\". The wrapper:\n\n- handles WAI-ARIA grid semantics, keyboard navigation, focus\n management, copy/paste, range selection;\n- wires virtualisation, column resize, fit-to-width, pinning, the\n filter menu, the column menu, the row-number column;\n- exposes a `SvGridApi` for data + columns + sort + filter +\n visibility mutations;\n- emits callbacks (`onSortingChange`, `onFiltersChange`,\n `onRowSelectionChange`, `onCellValueChange`) for parents that want to\n observe.\n\nReach for the headless core when:\n\n- you need a renderer the default cannot produce (canvas, mobile-only,\n Excel-export-only),\n- you're embedding the grid in an environment without a real DOM (SSR,\n static-site generators, PDF pipelines),\n- you want to drive multiple coordinated grids from one state store,\n- you're building a higher-level abstraction on top of SvGrid and want\n the headless API as your foundation.\n\n## The trade-off, named\n\nHeadless costs you a default theme. You can't `npm install` a\n\"complete-looking grid\" and have it match your app out of the box;\nevery grid library that promises that has to ship CSS and DOM\nassumptions you'll eventually fight.\n\nSvGrid splits the difference: the headless core is its own thing, and\n`<SvGrid>` is a *reference renderer* you can copy and modify. The\nshipped CSS uses `--sg-*` custom properties so you can re-theme it\nwithout forking. See [Tailwind integration](./help/tailwind.md) for a\nworked example.\n\n## See also\n\n- [Getting started](./getting-started.md) - the wrapper-first walkthrough\n- [Column definitions](./help/columns/column-definitions.md) - the contract the headless core enforces\n- [Filter API](./help/filtering/filter-api.md) - example of headless state surfaced through the wrapper\n- [`createSvGrid` source](../packages/grid/src/createGrid.svelte.ts)\n- Demo [`19-ssr`](../examples/src/demos/19-ssr.svelte) - SSR with the headless core\n"
4829
+ "markdown": "# Why headless?\r\n\r\nSvGrid is **headless at the core**, with a fully-styled Svelte component\r\nshipped on top. That two-layer split is deliberate, and worth\r\nunderstanding before you reach for either.\r\n\r\n## What \"headless\" actually means here\r\n\r\nThe core - `createSvGrid` from `@svgrid/grid/core` - knows about\r\nrows, columns, sorting, filtering, grouping, pagination, expansion, and\r\nselection. It does **not** know about pixels, DOM, ARIA, or CSS. It is\r\na state machine over your data that you query and mutate from Svelte.\r\n\r\nThe `/core` subpath is the headless surface on its own: no components, no\r\nCSS, nothing that touches the DOM. Every symbol on it is also re-exported\r\nfrom the main `@svgrid/grid` barrel, so either import works and you can mix\r\nthem. Import from `/core` when you want the dependency to be obvious in\r\nreview and the bundle cost to be provable.\r\n\r\nThe component - `<SvGrid>` - is one (opinionated) way to render that\r\nstate machine into a `<table>`. It is itself written against the\r\nheadless core, so the same hooks are available to you if you want to\r\nwrite your own renderer.\r\n\r\n```text\r\n┌──────────────────────────────────────────────────────┐\r\n│ Your app │\r\n└───────────────┬──────────────────────────────────────┘\r\n │\r\n ▼\r\n ┌─────────────────────┐\r\n │ <SvGrid> (Svelte) │ ← default renderer, ARIA,\r\n │ FlexRender │ keyboard, drag handles,\r\n │ formatters, menus │ theme tokens\r\n └─────────┬───────────┘\r\n │\r\n ▼\r\n ┌─────────────────────┐\r\n │ createSvGrid() │ ← rows × cols × state\r\n │ row models │ sort / filter / page /\r\n │ features │ group / expand\r\n └─────────────────────┘\r\n```\r\n\r\n## What you get from headless\r\n\r\n**1. The renderer is replaceable.** Want a virtualised React grid? A\r\ncanvas-based renderer for 1 M rows? A read-only `<table>` for a printed\r\nreport? `createSvGrid` returns the same state machine for all of them.\r\nYou write the markup, you keep the headless brain.\r\n\r\n```ts\r\nimport { createSvGrid, createCoreRowModel, createSortedRowModel,\r\n tableFeatures, rowSortingFeature, sortFns } from '@svgrid/grid/core'\r\n\r\nconst grid = createSvGrid({\r\n _features: tableFeatures({ rowSortingFeature }),\r\n _rowModels: {\r\n coreRowModel: createCoreRowModel(),\r\n sortedRowModel: createSortedRowModel(sortFns),\r\n },\r\n columns,\r\n data,\r\n})\r\n\r\n// Your own render loop:\r\nfor (const row of grid.getRowModel().rows) {\r\n for (const cell of row.getAllCells()) drawCell(cell)\r\n}\r\n```\r\n\r\nHere is exactly that - the same headless brain, rendered as a plain\r\nhand-styled `<table>` instead of `<SvGrid>`. Sort and filter are the engine's;\r\nthe markup is the demo's:\r\n\r\n<div data-docs-demo=\"186-headless-table\" data-height=\"480\"></div>\r\n\r\n**2. Features are opt-in modules.** Monolithic grid libraries ship everything\r\nin one bundle. With SvGrid you only register what you use:\r\n\r\n```ts\r\nimport { tableFeatures, rowSortingFeature } from '@svgrid/grid/core'\r\n\r\n// no filtering, no grouping, no pagination - none of that code is\r\n// reachable from this grid instance\r\nconst features = tableFeatures({ rowSortingFeature })\r\n```\r\n\r\nThe features object is the contract the headless core checks for\r\noptional capabilities. Each feature ships a small chunk of state +\r\nhelpers; if it's not in `tableFeatures()`, the core never asks for it\r\nand Vite tree-shakes away the rest.\r\n\r\n**3. Tests are fast and DOM-free.** `createSvGrid` runs without a\r\nbrowser:\r\n\r\n```ts\r\nimport { createSvGrid, ... } from '@svgrid/grid/core'\r\n\r\ntest('sorts by salary descending', () => {\r\n const grid = createSvGrid({ ..., state: { sorting: [{ id: 'salary', desc: true }] } })\r\n const rows = grid.getRowModel().rows\r\n expect(rows[0]!.getValue('salary')).toBeGreaterThan(rows[1]!.getValue('salary'))\r\n})\r\n```\r\n\r\nNo JSDOM, no Playwright, no test renderer. The headless contract is the\r\nunit of test.\r\n\r\n**4. Server-side rendering is a non-feature.** Because the core has no\r\nDOM, you can call `grid.getRowModel().rows` inside a SvelteKit\r\n`+page.server.ts` and pre-bake the table HTML before it ever reaches\r\nthe browser. Demo [`19-ssr`](../examples/src/demos/19-ssr.svelte)\r\nwalks through that.\r\n\r\n**5. State is yours to own.** Sort clauses, filter predicates, expansion\r\nstate, selection state - all of it lives in a `store` you can serialise\r\nto a URL, sync to a query string, or restore from `localStorage`. The\r\ndefault `<SvGrid>` wires this up for you, but the wires are visible:\r\n\r\n```ts\r\n// Persist\r\nlocalStorage.setItem('grid', JSON.stringify(grid.getState()))\r\n\r\n// Restore\r\nconst saved = JSON.parse(localStorage.getItem('grid')!)\r\ngrid.store.setState((prev) => ({ ...prev, ...saved }))\r\n```\r\n\r\n## When the wrapper is the right tool anyway\r\n\r\nYou won't usually write a custom renderer. `<SvGrid>` is the default\r\nbecause the 80% case is \"I want a table, with sort and filter, that\r\nlooks correct\". The wrapper:\r\n\r\n- handles WAI-ARIA grid semantics, keyboard navigation, focus\r\n management, copy/paste, range selection;\r\n- wires virtualisation, column resize, fit-to-width, pinning, the\r\n filter menu, the column menu, the row-number column;\r\n- exposes a `SvGridApi` for data + columns + sort + filter +\r\n visibility mutations;\r\n- emits callbacks (`onSortingChange`, `onFiltersChange`,\r\n `onRowSelectionChange`, `onCellValueChange`) for parents that want to\r\n observe.\r\n\r\nReach for the headless core when:\r\n\r\n- you need a renderer the default cannot produce (canvas, mobile-only,\r\n Excel-export-only),\r\n- you're embedding the grid in an environment without a real DOM (SSR,\r\n static-site generators, PDF pipelines),\r\n- you want to drive multiple coordinated grids from one state store,\r\n- you're building a higher-level abstraction on top of SvGrid and want\r\n the headless API as your foundation.\r\n\r\n## The trade-off, named\r\n\r\nHeadless costs you a default theme. You can't `npm install` a\r\n\"complete-looking grid\" and have it match your app out of the box;\r\nevery grid library that promises that has to ship CSS and DOM\r\nassumptions you'll eventually fight.\r\n\r\nSvGrid splits the difference: the headless core is its own thing, and\r\n`<SvGrid>` is a *reference renderer* you can copy and modify. The\r\nshipped CSS uses `--sg-*` custom properties so you can re-theme it\r\nwithout forking. See [Tailwind integration](./help/tailwind.md) for a\r\nworked example.\r\n\r\n## See also\r\n\r\n- [Getting started](./getting-started.md) - the wrapper-first walkthrough\r\n- [Column definitions](./help/columns/column-definitions.md) - the contract the headless core enforces\r\n- [Filter API](./help/filtering/filter-api.md) - example of headless state surfaced through the wrapper\r\n- [`createSvGrid` source](../packages/grid/src/createGrid.svelte.ts)\r\n- Demo [`19-ssr`](../examples/src/demos/19-ssr.svelte) - SSR with the headless core\r\n"
4812
4830
  }
4813
4831
  ];
4814
4832
  export const apiReference = {