@svgrid/mcp 3.0.21 → 3.0.23

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
@@ -5,7 +5,7 @@ export const examples = [
5
5
  "title": "Trading Desk",
6
6
  "category": "Getting Started",
7
7
  "blurb": "00. Trading desk - hero demo The first demo a prospect sees. Ten thousand live securities with sparklines, sector chips, pinned P&L, and a 500 ms tick stream that",
8
- "source": "<script lang=\"ts\">\n /**\n * 00. Trading desk - hero demo\n * ----------------------------\n * The first demo a prospect sees. Ten thousand live securities with\n * sparklines, sector chips, pinned P&L, and a 500 ms tick stream that\n * flashes cells on price moves. Built on the same SvGrid primitives as\n * every other demo - no special hero APIs.\n *\n * Highlights:\n * - Row virtualization keeps 10,000 rows scrolling at 60 fps\n * - Symbol pinned LEFT, P&L pinned RIGHT (sticky horizontal scroll)\n * - Live tick: ~5 % of rows update every 500 ms, cells pulse green/red\n * - Sparklines re-rendered from each row's rolling 30-point buffer\n * - KPI strip recomputes across all 10k rows on every tick via $derived\n * - Sector filter chips above the grid drive a $derived row slice\n *\n * Implementation notes:\n * - Rows live in `$state.raw` so the grid sees one new array reference\n * per tick instead of 10k per-cell mutations\n * - `pulses` map is replaced (not appended to) each tick - pulses GC\n * themselves by being absent from the next tick's map\n * - Sparkline path is recomputed only when the row reference changes\n * (i.e. only ticked rows), so static rows pay no SVG cost\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n renderSnippet,\n type ColumnDef,\n } from \"@svgrid/grid\";\n\n type Sector =\n | \"Technology\"\n | \"Financials\"\n | \"Healthcare\"\n | \"Energy\"\n | \"Consumer\"\n | \"Industrials\"\n | \"Materials\"\n | \"Utilities\"\n | \"Communication\"\n | \"Real Estate\";\n\n type RiskTier = \"Low\" | \"Med\" | \"High\";\n\n type Security = {\n symbol: string;\n name: string;\n sector: Sector;\n last: number;\n open: number;\n change: number;\n pctChange: number;\n bid: number;\n ask: number;\n spread: number;\n volume: number;\n history: number[]; // rolling 30-point trail used for the sparkline\n position: number; // current shares held\n positionCap: number; // risk cap in shares (for the % bar)\n pnl: number; // mark-to-market P&L in USD\n risk: RiskTier;\n };\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature });\n\n // ---- Deterministic PRNG so the seed is identical every load. Same\n // pattern as 20-industrial-dashboard so screenshots are reproducible.\n let prng = 0xc0ffee15;\n function rand(): number {\n prng = (prng * 1664525 + 1013904223) >>> 0;\n return prng / 0xffffffff;\n }\n function pick<T>(arr: readonly T[]): T {\n return arr[Math.floor(rand() * arr.length)]!;\n }\n function round(n: number, p = 2): number {\n const m = 10 ** p;\n return Math.round(n * m) / m;\n }\n\n // Give every symbol its OWN brand colour + emblem (deterministic from the\n // ticker), so the logo tiles read like distinct real company marks instead of\n // ten repeated sector tints. A curated palette of vivid brand colours (not raw\n // HSL, which turns muddy at low lightness) keeps them crisp and varied.\n const EXCHANGES = [\"NASDAQ\", \"NYSE\", \"LSE\", \"TSX\", \"XETRA\", \"HKEX\"] as const;\n // Vivid, visually distinct base colours - the kind real logos use.\n const BRAND_COLORS = [\n \"#4f46e5\", \"#2563eb\", \"#0ea5e9\", \"#0891b2\", \"#0d9488\", \"#059669\",\n \"#16a34a\", \"#ca8a04\", \"#ea580c\", \"#dc2626\", \"#e11d48\", \"#db2777\",\n \"#c026d3\", \"#9333ea\", \"#7c3aed\", \"#0284c7\", \"#334155\",\n ] as const;\n function symbolHash(sym: string): number {\n let h = 2166136261;\n for (let i = 0; i < sym.length; i++) {\n h ^= sym.charCodeAt(i);\n h = Math.imul(h, 16777619);\n }\n return h >>> 0;\n }\n function brandColor(sym: string): string {\n return BRAND_COLORS[symbolHash(sym) % BRAND_COLORS.length]!;\n }\n function companyInitials(name: string, fallback: string): string {\n const parts = name.split(/\\s+/).filter(Boolean);\n const ini = parts.slice(0, 2).map((w) => w[0] ?? \"\").join(\"\");\n return (ini || fallback.slice(0, 2)).toUpperCase();\n }\n\n const SECTORS: readonly Sector[] = [\n \"Technology\",\n \"Financials\",\n \"Healthcare\",\n \"Energy\",\n \"Consumer\",\n \"Industrials\",\n \"Materials\",\n \"Utilities\",\n \"Communication\",\n \"Real Estate\",\n ];\n\n // Per-sector accent, matching the inline chip palette. Fed to the list editor\n // as `editorOptions` so the dropdown paints each choice as a coloured pill.\n const SECTOR_COLORS: Record<Sector, string> = {\n Technology: \"#2563eb\",\n Financials: \"#16a34a\",\n Healthcare: \"#db2777\",\n Energy: \"#d97706\",\n Consumer: \"#7c3aed\",\n Industrials: \"#475569\",\n Materials: \"#dc2626\",\n Utilities: \"#0d9488\",\n Communication: \"#4f46e5\",\n \"Real Estate\": \"#ea580c\",\n };\n const SECTOR_OPTIONS = SECTORS.map((s) => ({\n value: s,\n label: s,\n color: SECTOR_COLORS[s],\n }));\n const RISK_OPTIONS = [\n { value: \"Low\", label: \"Low\", color: \"#16a34a\" },\n { value: \"Med\", label: \"Med\", color: \"#d97706\" },\n { value: \"High\", label: \"High\", color: \"#dc2626\" },\n ];\n\n // Fictional but real-sounding companies. A large first x descriptor x type\n // space so 10,000 rows rarely repeat a name, and the ticker is an abbreviation\n // of that name (deduped to stay unique) the way a real listing symbol is - so\n // you get BEAC / VRTX / NRDC, not AAA / AAB / AAC.\n const FIRST_WORDS = [\n \"Nordic\", \"Pacific\", \"Atlas\", \"Vertex\", \"Quantum\", \"Helios\", \"Stellar\", \"Apex\",\n \"Crescent\", \"Sigma\", \"Pioneer\", \"Aurora\", \"Granite\", \"Cobalt\", \"Hyperion\", \"Meridian\",\n \"Polaris\", \"Sentinel\", \"Tessera\", \"Vantage\", \"Cascade\", \"Frontier\", \"Lumen\", \"Beacon\",\n \"Cardinal\", \"Orion\", \"Solstice\", \"Summit\", \"Ironwood\", \"Redwood\", \"Silverline\", \"Keystone\",\n \"Northwind\", \"Brightwater\", \"Copper\", \"Everest\", \"Halcyon\", \"Kestrel\", \"Nimbus\", \"Onyx\",\n \"Pinnacle\", \"Rampart\", \"Seabright\", \"Thornton\", \"Vega\", \"Westford\", \"Ashford\", \"Belmont\",\n \"Corvus\", \"Delphi\", \"Ember\", \"Fairmont\", \"Grayson\", \"Harborview\", \"Ridgeline\", \"Sable\",\n \"Trident\", \"Ridgewood\", \"Wexford\", \"Auric\", \"Blackpine\", \"Clearwater\", \"Dunmore\", \"Larkspur\",\n ];\n const DESCRIPTORS = [\n \"Bio\", \"Energy\", \"Materials\", \"Logistics\", \"Dynamics\", \"Robotics\", \"Analytics\",\n \"Semiconductor\", \"Pharma\", \"Mining\", \"Aerospace\", \"Digital\", \"Capital\", \"Metals\",\n \"Renewables\", \"Health\", \"Foods\", \"Grid\", \"Cloud\", \"Data\", \"Motors\", \"Chemical\",\n \"Petroleum\", \"Software\", \"Devices\", \"Textiles\", \"Beverage\", \"Freight\", \"Payments\", \"Media\",\n \"Security\", \"Instruments\", \"Optics\", \"Marine\", \"Storage\", \"Gaming\", \"Networks\", \"Systems\",\n ];\n const CORP_TYPES = [\n \"Holdings\", \"Group\", \"Partners\", \"Corp\", \"Inc\", \"Industries\", \"Technologies\", \"Labs\",\n \"Ventures\", \"Trust\", \"Global\", \"Enterprises\", \"Solutions\", \"Company\", \"International\", \"Resources\",\n ];\n\n /** A mostly-unique, real-sounding company name as its component words. */\n function makeName(usedNames: Set<string>): string[] {\n for (let t = 0; t < 12; t++) {\n const first = pick(FIRST_WORDS);\n const roll = rand();\n const words =\n roll < 0.45\n ? [first, pick(DESCRIPTORS), pick(CORP_TYPES)]\n : roll < 0.75\n ? [first, pick(DESCRIPTORS)]\n : [first, pick(CORP_TYPES)];\n const key = words.join(\" \");\n if (!usedNames.has(key)) {\n usedNames.add(key);\n return words;\n }\n }\n const words = [pick(FIRST_WORDS), pick(DESCRIPTORS), pick(CORP_TYPES)];\n usedNames.add(words.join(\" \"));\n return words;\n }\n\n /** An abbreviation-style ticker for a name, unique across the seed. */\n function makeTicker(words: string[], used: Set<string>): string {\n const parts = words.map((w) => w.toUpperCase().replace(/[^A-Z]/g, \"\"));\n const f = parts[0] ?? \"CO\";\n const s = parts[1] ?? \"\";\n const th = parts[2] ?? \"\";\n const candidates = [\n f.slice(0, 4),\n f.slice(0, 3),\n f.slice(0, 2) + s.slice(0, 2),\n f.slice(0, 3) + s.slice(0, 1),\n f.slice(0, 1) + s.slice(0, 1) + th.slice(0, 1),\n f.slice(0, 2) + s.slice(0, 1) + th.slice(0, 1),\n ];\n for (const c of candidates) {\n if (c.length >= 3 && c.length <= 4 && !used.has(c)) {\n used.add(c);\n return c;\n }\n }\n const base = f.slice(0, 3) || \"COR\";\n for (let k = 0; k < 26; k++) {\n const c = base + String.fromCharCode(65 + k);\n if (!used.has(c)) {\n used.add(c);\n return c;\n }\n }\n let n = 1;\n while (used.has(`X${n}`)) n++;\n used.add(`X${n}`);\n return `X${n}`;\n }\n\n function makeRow(\n i: number,\n usedNames: Set<string>,\n usedTickers: Set<string>,\n ): Security {\n const words = makeName(usedNames);\n const name = words.join(\" \");\n const symbol = makeTicker(words, usedTickers);\n const sector = SECTORS[i % SECTORS.length]!;\n // Price range tuned so big-cap-ish names live near the top of the seed.\n const basePrice = round(8 + rand() * 990, 2);\n const open = basePrice;\n // Pre-seed the 30-point trail. Each name gets its OWN trend + volatility so\n // the sparklines read like distinct real charts - steady climbers, choppy\n // names, sliders - instead of identical noise. Multiplicative walk with a\n // per-name drift and a per-name step size.\n const trend = round((rand() - 0.5) * 0.5, 4); // total drift over the trail: -25%..+25%\n const vol = 0.005 + rand() * 0.018; // per-step volatility 0.5%..2.3%\n const history: number[] = [];\n let h = basePrice;\n for (let k = 0; k < 30; k++) {\n h = Math.max(0.5, h * (1 + trend / 30 + (rand() - 0.5) * vol));\n history.push(round(h, 2));\n }\n const last = history[history.length - 1]!;\n const change = round(last - open, 2);\n const pctChange = round((change / open) * 100, 2);\n const spread = Math.max(round(last * 0.0006, 2), 0.01);\n const positionCap = 500 + Math.floor(rand() * 9500);\n const position = Math.floor(positionCap * (0.1 + rand() * 0.85));\n const riskRoll = rand();\n const risk: RiskTier =\n riskRoll < 0.55 ? \"Low\" : riskRoll < 0.88 ? \"Med\" : \"High\";\n return {\n symbol,\n name,\n sector,\n last,\n open,\n change,\n pctChange,\n bid: round(last - spread, 2),\n ask: round(last + spread, 2),\n spread,\n volume: Math.floor(50_000 + rand() * 5_000_000),\n history,\n position,\n positionCap,\n pnl: round(position * change, 0),\n risk,\n };\n }\n\n function makeSeed(n: number): Security[] {\n const usedNames = new Set<string>();\n const usedTickers = new Set<string>();\n const out: Security[] = new Array(n);\n for (let i = 0; i < n; i++) out[i] = makeRow(i, usedNames, usedTickers);\n return out;\n }\n\n // ---- State\n\n const ROW_COUNT = 10_000;\n let rows = $state.raw<Security[]>(makeSeed(ROW_COUNT));\n let pulses = $state.raw<Record<string, \"up\" | \"down\">>({});\n let paused = $state(false);\n let ticks = $state(0);\n let sectorFilter = $state<Sector | \"All\">(\"All\");\n\n // ---- Notifications: a lightweight desk feed. The tick loop surfaces the odd\n // notable move as a bell entry (throttled so it stays an occasional\n // heads-up, never a stream). Purely local demo state - no engine, no watching.\n type Notif = {\n id: number;\n symbol: string;\n tone: \"up\" | \"down\";\n title: string;\n detail: string;\n };\n let notifications = $state<Notif[]>([]);\n let unread = $state(0);\n let bellOpen = $state(false);\n let notifSeq = 0;\n let lastNotifTick = -100;\n const NOTIF_GAP = 6; // min ticks between notifications (~3 s at 500 ms)\n const NOTIF_PCT = 10; // only flag names at least this far from open\n\n function pushNotif(top: Security) {\n lastNotifTick = ticks;\n const up = top.pctChange >= 0;\n const n: Notif = {\n id: ++notifSeq,\n symbol: top.symbol,\n tone: up ? \"up\" : \"down\",\n title: `${top.symbol} ${up ? \"+\" : \"\"}${top.pctChange.toFixed(1)}% today`,\n detail: `${top.name} · ${fmtUsd(top.last)} · ${top.risk} risk`,\n };\n notifications = [n, ...notifications].slice(0, 24);\n unread += 1;\n }\n\n function toggleBell() {\n bellOpen = !bellOpen;\n if (bellOpen) unread = 0;\n }\n function clearNotifs() {\n notifications = [];\n unread = 0;\n }\n\n // Close the bell dropdown on an outside click.\n $effect(() => {\n if (!bellOpen) return;\n const onDown = (e: MouseEvent) => {\n const t = e.target;\n if (t instanceof Element && t.closest(\".td-notif\")) return;\n bellOpen = false;\n };\n window.addEventListener(\"mousedown\", onDown);\n return () => window.removeEventListener(\"mousedown\", onDown);\n });\n\n // Columns that should flash on tick. Symbol / Name / Sector are static\n // so they're excluded - pulsing them is misleading.\n const PULSED_COLS = [\n \"last\",\n \"change\",\n \"pctChange\",\n \"bid\",\n \"ask\",\n \"volume\",\n \"spread\",\n \"pnl\",\n ];\n\n function tick() {\n const nextPulses: Record<string, \"up\" | \"down\"> = {};\n // Update ~5 % of rows per tick. Touching every row each tick is\n // expensive AND distracting visually - 500 active rows per tick is\n // the sweet spot for \"feels alive but readable\".\n const touchCount = Math.floor(ROW_COUNT * 0.05);\n const touchSet = new Set<number>();\n while (touchSet.size < touchCount) {\n touchSet.add(Math.floor(rand() * ROW_COUNT));\n }\n\n const nextRows = rows.slice();\n // Track the most extreme mover among the rows that ticked, for the desk feed.\n let topMover: Security | null = null;\n for (const idx of touchSet) {\n const row = rows[idx]!;\n const drift = (rand() - 0.5) * row.last * 0.006; // up to ±0.3 %\n const newLast = Math.max(round(row.last + drift, 2), 0.01);\n if (newLast === row.last) continue;\n const direction: \"up\" | \"down\" = newLast > row.last ? \"up\" : \"down\";\n const change = round(newLast - row.open, 2);\n const pctChange = round((change / row.open) * 100, 2);\n const spread = Math.max(round(newLast * 0.0006, 2), 0.01);\n const history = row.history.slice(1);\n history.push(newLast);\n const next: Security = {\n ...row,\n last: newLast,\n change,\n pctChange,\n bid: round(newLast - spread, 2),\n ask: round(newLast + spread, 2),\n spread,\n volume: row.volume + Math.floor(50 + rand() * 4_000),\n history,\n pnl: round(row.position * change, 0),\n };\n for (const col of PULSED_COLS) {\n nextPulses[`${row.symbol}:${col}`] = direction;\n }\n nextRows[idx] = next;\n if (!topMover || Math.abs(next.pctChange) > Math.abs(topMover.pctChange)) {\n topMover = next;\n }\n }\n rows = nextRows;\n pulses = nextPulses;\n ticks += 1;\n // Occasionally surface the standout mover as a bell notification.\n if (\n topMover &&\n Math.abs(topMover.pctChange) >= NOTIF_PCT &&\n ticks - lastNotifTick >= NOTIF_GAP\n ) {\n pushNotif(topMover);\n }\n }\n\n $effect(() => {\n if (paused) return;\n const id = setInterval(tick, 500);\n return () => clearInterval(id);\n });\n\n function pulseClass(\n row: Security,\n colId: string,\n ): \"sv-pulse-up\" | \"sv-pulse-down\" | \"\" {\n const dir = pulses[`${row.symbol}:${colId}`];\n return dir === \"up\" ? \"sv-pulse-up\" : dir === \"down\" ? \"sv-pulse-down\" : \"\";\n }\n\n // Pipe user edits back into the parent `rows` state. Without this, every\n // 500 ms tick replaces `rows` with a fresh array (and the grid re-syncs\n // its internalData + drops `editedCellValues`) - wiping the user's input.\n // We also recompute pnl on a position change so the value the user just\n // typed shows updated P&L immediately, not on the next price tick.\n function onCellValueChange(args: {\n rowIndex: number;\n columnId: string;\n newValue: unknown;\n }): void {\n const visible = visibleRows;\n const target = visible[args.rowIndex];\n if (!target) return;\n const sourceIdx = rows.indexOf(target);\n if (sourceIdx < 0) return;\n const updated: Security = { ...target };\n if (args.columnId === \"position\") {\n const n = Number(args.newValue);\n if (!Number.isFinite(n)) return;\n updated.position = Math.max(0, Math.floor(n));\n updated.pnl = round(updated.position * updated.change, 0);\n } else if (args.columnId === \"risk\") {\n const v = String(args.newValue);\n if (v !== \"Low\" && v !== \"Med\" && v !== \"High\") return;\n updated.risk = v as RiskTier;\n } else if (args.columnId === \"sector\") {\n const v = String(args.newValue);\n if (!SECTORS.includes(v as Sector)) return;\n updated.sector = v as Sector;\n } else {\n return;\n }\n const next = rows.slice();\n next[sourceIdx] = updated;\n rows = next;\n }\n\n // ---- Derived: filtered rows + KPI strip\n const visibleRows = $derived(\n sectorFilter === \"All\"\n ? rows\n : rows.filter((r) => r.sector === sectorFilter),\n );\n\n const kpis = $derived.by(() => {\n let pnl = 0;\n let exposure = 0;\n let volume = 0;\n let spreadSum = 0;\n let advancers = 0;\n let decliners = 0;\n for (const r of visibleRows) {\n pnl += r.pnl;\n exposure += r.position * r.last;\n volume += r.volume;\n spreadSum += r.spread;\n if (r.change > 0) advancers += 1;\n else if (r.change < 0) decliners += 1;\n }\n const avgSpread = visibleRows.length ? spreadSum / visibleRows.length : 0;\n return {\n pnl,\n exposure,\n volume,\n avgSpread,\n advancers,\n decliners,\n count: visibleRows.length,\n };\n });\n\n // ---- Sparkline helpers\n\n function sparkPath(history: number[], w: number, h: number): string {\n const min = Math.min(...history);\n const max = Math.max(...history);\n const range = Math.max(1e-6, max - min);\n const step = w / (history.length - 1);\n // Vertical padding so the line never sits flush against the top/bottom edge -\n // reads cleaner and lets a nearly-flat series actually look flat.\n const pad = h * 0.16;\n const inner = h - pad * 2;\n let d = \"\";\n for (let i = 0; i < history.length; i++) {\n const x = round(i * step, 2);\n const y = round(pad + (1 - (history[i]! - min) / range) * inner, 2);\n d += `${i === 0 ? \"M\" : \"L\"}${x} ${y} `;\n }\n return d.trim();\n }\n\n // ---- Formatters\n function fmtUsd(n: number, frac = 2): string {\n return n.toLocaleString(\"en-US\", {\n style: \"currency\",\n currency: \"USD\",\n minimumFractionDigits: frac,\n maximumFractionDigits: frac,\n });\n }\n function fmtUsdShort(n: number): string {\n const abs = Math.abs(n);\n if (abs >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)} B`;\n if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(2)} M`;\n if (abs >= 1_000) return `${(n / 1_000).toFixed(1)} K`;\n return n.toFixed(0);\n }\n function fmtPnl(n: number): string {\n const sign = n >= 0 ? \"+\" : \"-\";\n return `${sign}${fmtUsdShort(Math.abs(n))}`;\n }\n</script>\n\n<!-- ───────────────────────── CELL SNIPPETS ───────────────────────── -->\n\n{#snippet LogoMark(m: { sym: string; name: string })}\n {@const c = brandColor(m.sym)}\n {@const style = symbolHash(m.sym) % 6}\n {@const roundTile = symbolHash(m.sym) % 2 === 0}\n {@const initials = companyInitials(m.name, m.sym)}\n <svg class=\"td-logo\" viewBox=\"0 0 28 28\" width=\"27\" height=\"27\" aria-hidden=\"true\">\n <!-- Brand tile: per-company colour, alternating rounded-square / disc, with a\n soft top sheen + darker foot so it reads like a moulded logo chip. -->\n {#if roundTile}\n <circle cx=\"14\" cy=\"14\" r=\"13\" fill={c} />\n <path d=\"M2 12 A13 13 0 0 1 26 12 L26 3 L2 3 Z\" fill=\"#fff\" opacity=\"0.16\" />\n <path d=\"M2 17 A13 13 0 0 0 26 17 L26 25 L2 25 Z\" fill=\"#000\" opacity=\"0.12\" />\n {:else}\n <rect x=\"1\" y=\"1\" width=\"26\" height=\"26\" rx=\"7.5\" fill={c} />\n <rect x=\"1\" y=\"1\" width=\"26\" height=\"12\" rx=\"7.5\" fill=\"#fff\" opacity=\"0.16\" />\n <rect x=\"1\" y=\"16\" width=\"26\" height=\"11\" rx=\"7.5\" fill=\"#000\" opacity=\"0.1\" />\n {/if}\n <!-- Mark: a distinct emblem per company (deterministic from the ticker). -->\n {#if style === 0}\n <circle cx=\"14\" cy=\"14\" r=\"6\" fill=\"none\" stroke=\"#fff\" stroke-width=\"2.2\" opacity=\"0.92\" />\n <circle cx=\"14\" cy=\"14\" r=\"1.9\" fill=\"#fff\" />\n {:else if style === 1}\n <path d=\"M9 18.5 L13.5 9.5 M14.5 18.5 L19 9.5\" stroke=\"#fff\" stroke-width=\"2.3\" stroke-linecap=\"round\" opacity=\"0.92\" />\n {:else if style === 2}\n <rect x=\"8\" y=\"15\" width=\"3.1\" height=\"5\" rx=\"1\" fill=\"#fff\" opacity=\"0.92\" />\n <rect x=\"12.45\" y=\"11.5\" width=\"3.1\" height=\"8.5\" rx=\"1\" fill=\"#fff\" opacity=\"0.92\" />\n <rect x=\"16.9\" y=\"8\" width=\"3.1\" height=\"12\" rx=\"1\" fill=\"#fff\" opacity=\"0.92\" />\n {:else if style === 3}\n <path d=\"M14 8 L20 19 L8 19 Z\" fill=\"#fff\" opacity=\"0.92\" />\n {:else if style === 4}\n <circle cx=\"11.6\" cy=\"14\" r=\"4.8\" fill=\"#fff\" opacity=\"0.5\" />\n <circle cx=\"16.4\" cy=\"14\" r=\"4.8\" fill=\"#fff\" opacity=\"0.5\" />\n {:else}\n <text\n x=\"14\"\n y=\"18.4\"\n text-anchor=\"middle\"\n font-size=\"11\"\n font-weight=\"800\"\n fill=\"#fff\"\n font-family=\"ui-monospace, SFMono-Regular, Menlo, monospace\">{initials}</text>\n {/if}\n </svg>\n{/snippet}\n\n{#snippet SymbolCell(props: { row: Security })}\n {@const exchange = EXCHANGES[symbolHash(props.row.symbol) % EXCHANGES.length]}\n <span class=\"td-symbol\">\n {@render LogoMark({ sym: props.row.symbol, name: props.row.name })}\n <span class=\"td-sym-meta\">\n <span class=\"td-sym-ticker\">{props.row.symbol}</span>\n <span class=\"td-sym-exch\">{exchange}</span>\n </span>\n </span>\n{/snippet}\n\n{#snippet SectorChip(props: { row: Security })}\n <span\n class=\"td-sector td-sec-{props.row.sector\n .toLowerCase()\n .replace(/\\s+/g, '-')}\"\n >\n {props.row.sector}\n </span>\n{/snippet}\n\n{#snippet LastCell(props: { row: Security })}\n <span class={`td-tick ${pulseClass(props.row, \"last\")}`}>\n {fmtUsd(props.row.last)}\n </span>\n{/snippet}\n\n{#snippet ChangeCell(props: { row: Security })}\n {@const positive = props.row.change >= 0}\n <span\n class={`td-tick ${pulseClass(props.row, \"change\")} ${positive ? \"td-up\" : \"td-down\"}`}\n >\n <svg viewBox=\"0 0 12 12\" width=\"9\" height=\"9\" aria-hidden=\"true\">\n {#if positive}\n <path d=\"M6 2 L11 9 L1 9 Z\" fill=\"currentColor\" />\n {:else}\n <path d=\"M6 10 L1 3 L11 3 Z\" fill=\"currentColor\" />\n {/if}\n </svg>\n {Math.abs(props.row.change).toFixed(2)}\n </span>\n{/snippet}\n\n{#snippet PctChangeCell(props: { row: Security })}\n {@const positive = props.row.pctChange >= 0}\n <span\n class={`td-tick ${pulseClass(props.row, \"pctChange\")} ${positive ? \"td-up\" : \"td-down\"}`}\n >\n {positive ? \"+\" : \"\"}{props.row.pctChange.toFixed(2)}%\n </span>\n{/snippet}\n\n{#snippet TrendCell(props: { row: Security })}\n {@const h = props.row.history}\n {@const positive = h[h.length - 1]! >= h[0]!}\n {@const pct = ((h[h.length - 1]! - h[0]!) / h[0]!) * 100}\n <span class=\"td-trend\">\n <svg\n class={`td-spark ${positive ? \"td-up\" : \"td-down\"}`}\n style={`color: ${positive ? \"var(--td-spark-up)\" : \"var(--td-spark-down)\"}`}\n viewBox=\"0 0 120 32\"\n preserveAspectRatio=\"none\"\n aria-hidden=\"true\"\n >\n <path\n d={`${sparkPath(h, 120, 32)} L 120 32 L 0 32 Z`}\n fill=\"currentColor\"\n opacity=\"0.12\"\n />\n <path\n d={sparkPath(h, 120, 32)}\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"1.6\"\n stroke-linejoin=\"round\"\n stroke-linecap=\"round\"\n vector-effect=\"non-scaling-stroke\"\n />\n </svg>\n <span class={`td-spark-pct ${positive ? \"td-up\" : \"td-down\"}`}>\n {pct >= 0 ? \"+\" : \"\"}{pct.toFixed(1)}%\n </span>\n </span>\n{/snippet}\n\n{#snippet BidCell(props: { row: Security })}\n <span class={`td-tick ${pulseClass(props.row, \"bid\")}`}\n >{props.row.bid.toFixed(2)}</span\n >\n{/snippet}\n\n{#snippet AskCell(props: { row: Security })}\n <span class={`td-tick ${pulseClass(props.row, \"ask\")}`}\n >{props.row.ask.toFixed(2)}</span\n >\n{/snippet}\n\n{#snippet SpreadCell(props: { row: Security })}\n {@const tight = props.row.spread < 0.05}\n <span\n class={`td-tick ${pulseClass(props.row, \"spread\")} ${tight ? \"td-up\" : \"td-down\"}`}\n >\n {props.row.spread.toFixed(3)}\n </span>\n{/snippet}\n\n{#snippet VolumeCell(props: { row: Security })}\n <span class={`td-tick ${pulseClass(props.row, \"volume\")} td-vol`}>\n {fmtUsdShort(props.row.volume)}\n </span>\n{/snippet}\n\n{#snippet PositionCell(props: { row: Security })}\n {@const pct = props.row.positionCap\n ? props.row.position / props.row.positionCap\n : 0}\n {@const tone = pct >= 0.85 ? \"hot\" : pct >= 0.6 ? \"warm\" : \"cool\"}\n <div class=\"td-pos\">\n <div class=\"td-pos-bar\">\n <div\n class={`td-pos-fill td-pos-${tone}`}\n style=\"width: {Math.min(100, pct * 100)}%\"\n ></div>\n </div>\n <span class=\"td-pos-text\">{Math.round(pct * 100)}%</span>\n </div>\n{/snippet}\n\n{#snippet RiskCell(props: { row: Security })}\n <span class=\"td-risk td-risk-{props.row.risk.toLowerCase()}\"\n >{props.row.risk}</span\n >\n{/snippet}\n\n{#snippet PnlCell(props: { row: Security })}\n {@const positive = props.row.pnl >= 0}\n <span\n class={`td-tick td-pnl ${pulseClass(props.row, \"pnl\")} ${positive ? \"td-up\" : \"td-down\"}`}\n >\n {fmtPnl(props.row.pnl)}\n </span>\n{/snippet}\n\n<!-- ───────────────────────── LAYOUT ───────────────────────── -->\n\n<section class=\"td-shell flex flex-col flex-1 min-h-0 gap-3\">\n <!-- KPI strip -->\n <div class=\"td-kpi-strip\">\n <div class=\"td-kpi\">\n <div class=\"td-kpi-label\">P&amp;L (book)</div>\n <div\n class={`td-kpi-value tabular-nums ${kpis.pnl >= 0 ? \"td-up\" : \"td-down\"}`}\n >\n {fmtPnl(kpis.pnl)}\n </div>\n <div class=\"td-kpi-foot\">\n <span class=\"td-up\">▲ {kpis.advancers.toLocaleString()}</span>\n &nbsp;·&nbsp;\n <span class=\"td-down\">▼ {kpis.decliners.toLocaleString()}</span>\n </div>\n </div>\n <div class=\"td-kpi\">\n <div class=\"td-kpi-label\">Gross exposure</div>\n <div class=\"td-kpi-value tabular-nums\">{fmtUsdShort(kpis.exposure)}</div>\n <div class=\"td-kpi-foot\">\n {kpis.count.toLocaleString()} active symbols\n </div>\n </div>\n <div class=\"td-kpi\">\n <div class=\"td-kpi-label\">Daily volume</div>\n <div class=\"td-kpi-value tabular-nums\">{fmtUsdShort(kpis.volume)}</div>\n <div class=\"td-kpi-foot\">shares traded</div>\n </div>\n <div class=\"td-kpi\">\n <div class=\"td-kpi-label\">Avg spread</div>\n <div class=\"td-kpi-value tabular-nums\">${kpis.avgSpread.toFixed(3)}</div>\n <div class=\"td-kpi-foot\">across visible book</div>\n </div>\n <div class=\"td-kpi\">\n <div class=\"td-kpi-label\">Tick stream</div>\n <div class=\"td-kpi-value tabular-nums\">{paused ? \"PAUSED\" : \"LIVE\"}</div>\n <div class=\"td-kpi-foot\">\n <span class={paused ? \"\" : \"td-pulse-dot\"}></span>\n {ticks.toLocaleString()} ticks · 500 ms\n </div>\n </div>\n </div>\n\n <!-- Toolbar: sector chips + pause -->\n <div class=\"td-toolbar\">\n <!-- `.td-chips` is `display: contents` on desktop, so the chips lay out in\n the toolbar exactly as they did before it existed; on a phone it turns\n into one horizontally scrolling row (see the phone block in <style>). -->\n <div class=\"td-chips\">\n <button\n type=\"button\"\n class=\"td-chip td-chip-all\"\n class:td-chip-active={sectorFilter === \"All\"}\n onclick={() => (sectorFilter = \"All\")}\n >All <span class=\"td-chip-count\">{rows.length.toLocaleString()}</span\n ></button\n >\n {#each SECTORS as s (s)}\n <button\n type=\"button\"\n class={`td-chip td-sec-${s.toLowerCase().replace(/\\s+/g, \"-\")}`}\n class:td-chip-active={sectorFilter === s}\n onclick={() => (sectorFilter = s)}>{s}</button\n >\n {/each}\n </div>\n <div class=\"td-toolbar-spacer\"></div>\n <span class=\"td-hint\">\n Editable: <strong>Position</strong> · <strong>Sector</strong> ·\n <strong>Risk</strong>\n </span>\n <button type=\"button\" class=\"td-btn\" onclick={() => (paused = !paused)}\n >{paused ? \"▶ Resume\" : \"⏸ Pause\"}</button\n >\n\n <!-- Notifications bell -->\n <div class=\"td-notif\">\n <button\n type=\"button\"\n class=\"td-bell\"\n class:td-bell-on={unread > 0}\n onclick={toggleBell}\n aria-label={`Notifications${unread ? ` (${unread} unread)` : \"\"}`}\n aria-expanded={bellOpen}\n >\n <svg\n viewBox=\"0 0 24 24\"\n width=\"17\"\n height=\"17\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"1.9\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9\" />\n <path d=\"M13.7 21a2 2 0 0 1-3.4 0\" />\n </svg>\n {#if unread > 0}\n <span class=\"td-bell-badge\">{unread > 9 ? \"9+\" : unread}</span>\n {/if}\n </button>\n\n {#if bellOpen}\n <div class=\"td-notif-panel\" role=\"dialog\" aria-label=\"Notifications\">\n <div class=\"td-notif-head\">\n <span>Notifications</span>\n {#if notifications.length}\n <button type=\"button\" class=\"td-notif-clear\" onclick={clearNotifs}\n >Clear</button\n >\n {/if}\n </div>\n {#if notifications.length === 0}\n <div class=\"td-notif-empty\">You're all caught up.</div>\n {:else}\n <ul class=\"td-notif-list\">\n {#each notifications as n (n.id)}\n <li class={`td-notif-item td-${n.tone}`}>\n <span class=\"td-notif-dot\" aria-hidden=\"true\"></span>\n <div class=\"td-notif-text\">\n <span class=\"td-notif-title\">{n.title}</span>\n <span class=\"td-notif-detail\">{n.detail}</span>\n </div>\n </li>\n {/each}\n </ul>\n {/if}\n </div>\n {/if}\n </div>\n </div>\n\n <!-- Grid -->\n <div class=\"flex-1 min-h-0\">\n <SvGrid\n columnResize\n data={visibleRows}\n columns={[\n {\n field: \"symbol\",\n header: \"Symbol\",\n width: 180,\n editable: false,\n cell: (ctx) => renderSnippet(SymbolCell, { row: ctx.row.original }),\n },\n { field: \"name\", header: \"Company\", width: 230, editable: false, hideBelow: 700 },\n {\n field: \"sector\",\n header: \"Sector\",\n width: 140,\n hideBelow: 700,\n editorType: \"list\",\n editorOptions: SECTOR_OPTIONS,\n cell: (ctx) => renderSnippet(SectorChip, { row: ctx.row.original }),\n },\n {\n field: \"last\",\n header: \"Last\",\n editorType: \"number\",\n width: 110,\n editable: false,\n cell: (ctx) => renderSnippet(LastCell, { row: ctx.row.original }),\n },\n {\n field: \"change\",\n header: \"Δ\",\n editorType: \"number\",\n width: 95,\n editable: false,\n cell: (ctx) => renderSnippet(ChangeCell, { row: ctx.row.original }),\n },\n {\n field: \"pctChange\",\n header: \"Δ %\",\n editorType: \"number\",\n width: 90,\n editable: false,\n cell: (ctx) =>\n renderSnippet(PctChangeCell, { row: ctx.row.original }),\n },\n {\n field: \"history\",\n header: \"Trend (30 ticks)\",\n width: 180,\n editable: false,\n hideBelow: 700,\n cell: (ctx) => renderSnippet(TrendCell, { row: ctx.row.original }),\n },\n {\n field: \"bid\",\n header: \"Bid\",\n editorType: \"number\",\n width: 90,\n editable: false,\n hideBelow: 700,\n cell: (ctx) => renderSnippet(BidCell, { row: ctx.row.original }),\n },\n {\n field: \"ask\",\n header: \"Ask\",\n editorType: \"number\",\n width: 90,\n editable: false,\n hideBelow: 700,\n cell: (ctx) => renderSnippet(AskCell, { row: ctx.row.original }),\n },\n {\n field: \"spread\",\n header: \"Spread\",\n editorType: \"number\",\n width: 95,\n editable: false,\n hideBelow: 700,\n cell: (ctx) => renderSnippet(SpreadCell, { row: ctx.row.original }),\n },\n {\n field: \"volume\",\n header: \"Volume\",\n editorType: \"number\",\n width: 115,\n editable: false,\n hideBelow: 700,\n cell: (ctx) => renderSnippet(VolumeCell, { row: ctx.row.original }),\n },\n // The only editable column. Trader sets share count; the tick stream\n // never touches it, and our onCellValueChange handler pushes the\n // change back into the parent `rows` state so subsequent ticks pick\n // up the new value when they recompute P&L.\n {\n field: \"position\",\n header: \"Position\",\n editorType: \"number\",\n width: 160,\n cell: (ctx) => renderSnippet(PositionCell, { row: ctx.row.original }),\n },\n {\n field: \"risk\",\n header: \"Risk\",\n width: 95,\n hideBelow: 700,\n editorType: \"list\",\n editorOptions: RISK_OPTIONS,\n cell: (ctx) => renderSnippet(RiskCell, { row: ctx.row.original }),\n },\n {\n field: \"pnl\",\n header: \"P&L\",\n editorType: \"number\",\n width: 130,\n editable: false,\n cell: (ctx) => renderSnippet(PnlCell, { row: ctx.row.original }),\n },\n ] satisfies ColumnDef<typeof features, Security>[]}\n {features}\n filterMode=\"menu\"\n selectionMode=\"cell\"\n enableInlineEditing={true}\n enableCellSelection={true}\n rowHeight={40}\n containerHeight=\"100%\"\n fitColumns={false}\n columnVirtualization={false}\n initialColumnPinning={{ left: [\"symbol\"], right: [\"pnl\"] }}\n responsive={true}\n {onCellValueChange}\n />\n </div>\n</section>\n\n<style>\n /* Keep every data cell at one size. Without this the grid cells inherit the\n page's 16px, which reads oddly next to the 11-13px chips/labels - the tags\n looked smaller than the numbers. 13px is the standard dense-grid size. */\n :global(.td-shell .sv-grid-cell) {\n font-size: 13px;\n }\n\n /* ─── KPI strip ──────────────────────────────────────────────── */\n .td-kpi-strip {\n display: grid;\n grid-template-columns: repeat(5, minmax(0, 1fr));\n gap: 10px;\n flex-shrink: 0;\n }\n .td-kpi {\n border: 1px solid var(--sg-border, #e2e8f0);\n background: linear-gradient(\n 180deg,\n color-mix(in srgb, var(--sg-bg, #ffffff) 100%, transparent) 0%,\n color-mix(in srgb, var(--sg-header-bg, #f8fafc) 100%, transparent) 100%\n );\n border-radius: 10px;\n padding: 12px 14px;\n box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);\n }\n .td-kpi-label {\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.06em;\n color: var(--sg-muted, #64748b);\n margin-bottom: 6px;\n }\n .td-kpi-value {\n font-size: 22px;\n font-weight: 700;\n line-height: 1.1;\n letter-spacing: -0.01em;\n }\n .td-kpi-foot {\n margin-top: 6px;\n font-size: 11px;\n color: var(--sg-muted, #64748b);\n display: flex;\n align-items: center;\n gap: 4px;\n }\n .td-pulse-dot {\n display: inline-block;\n width: 7px;\n height: 7px;\n border-radius: 50%;\n background: #16a34a;\n box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.5);\n animation: td-dot-pulse 1.6s ease-out infinite;\n }\n @keyframes td-dot-pulse {\n 0% {\n box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.45);\n }\n 70% {\n box-shadow: 0 0 0 8px rgba(22, 163, 74, 0);\n }\n 100% {\n box-shadow: 0 0 0 0 rgba(22, 163, 74, 0);\n }\n }\n\n /* ─── Toolbar / sector chips ──────────────────────────────────── */\n .td-toolbar {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 6px;\n flex-shrink: 0;\n }\n .td-toolbar-spacer {\n flex: 1 1 auto;\n }\n .td-chips {\n display: contents;\n }\n .td-btn {\n border: 1px solid var(--sg-border, #cbd5e1);\n background: var(--sg-bg, #ffffff);\n color: var(--sg-fg, #1e293b);\n border-radius: 6px;\n padding: 4px 12px;\n font-size: 12px;\n cursor: pointer;\n }\n .td-btn:hover {\n background: var(--sg-row-hover-bg, #f1f5f9);\n }\n\n /* ─── Notifications bell + panel ──────────────────────────────── */\n .td-notif {\n position: relative;\n display: inline-flex;\n }\n .td-bell {\n position: relative;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 32px;\n height: 30px;\n border: 1px solid var(--sg-border, #cbd5e1);\n background: var(--sg-bg, #ffffff);\n color: var(--sg-fg, #1e293b);\n border-radius: 6px;\n cursor: pointer;\n transition:\n background 0.14s ease,\n border-color 0.14s ease;\n }\n .td-bell:hover {\n background: var(--sg-row-hover-bg, #f1f5f9);\n }\n .td-bell-on {\n color: var(--sg-accent, #2563eb);\n border-color: color-mix(in srgb, var(--sg-accent, #2563eb) 45%, var(--sg-border, #cbd5e1));\n }\n .td-bell-badge {\n position: absolute;\n top: -6px;\n right: -6px;\n min-width: 16px;\n height: 16px;\n padding: 0 4px;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n font-size: 10px;\n font-weight: 700;\n line-height: 1;\n color: #fff;\n background: #dc2626;\n border-radius: 999px;\n box-shadow: 0 0 0 2px var(--sg-bg, #fff);\n }\n .td-notif-panel {\n position: absolute;\n top: calc(100% + 8px);\n right: 0;\n z-index: 40;\n width: 300px;\n max-height: 360px;\n overflow: auto;\n background: var(--sg-bg, #ffffff);\n border: 1px solid var(--sg-border, #e2e8f0);\n border-radius: 10px;\n box-shadow: 0 12px 32px -8px rgba(15, 23, 42, 0.35);\n }\n .td-notif-head {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 10px 12px;\n font-size: 12px;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n color: var(--sg-muted, #64748b);\n border-bottom: 1px solid var(--sg-border, #e2e8f0);\n position: sticky;\n top: 0;\n background: var(--sg-bg, #ffffff);\n }\n .td-notif-clear {\n border: 0;\n background: transparent;\n color: var(--sg-accent, #2563eb);\n font-size: 11px;\n font-weight: 600;\n text-transform: none;\n letter-spacing: 0;\n cursor: pointer;\n }\n .td-notif-empty {\n padding: 28px 16px;\n text-align: center;\n font-size: 12.5px;\n color: var(--sg-muted, #64748b);\n }\n .td-notif-list {\n list-style: none;\n margin: 0;\n padding: 4px;\n display: flex;\n flex-direction: column;\n gap: 2px;\n }\n .td-notif-item {\n display: grid;\n grid-template-columns: auto 1fr;\n gap: 9px;\n align-items: start;\n padding: 9px 10px;\n border-radius: 8px;\n }\n .td-notif-item:hover {\n background: var(--sg-row-hover-bg, #f1f5f9);\n }\n .td-notif-dot {\n width: 8px;\n height: 8px;\n margin-top: 5px;\n border-radius: 50%;\n background: #16a34a;\n }\n .td-notif-item.td-down .td-notif-dot {\n background: #dc2626;\n }\n .td-notif-text {\n display: flex;\n flex-direction: column;\n min-width: 0;\n }\n .td-notif-title {\n font-size: 13px;\n font-weight: 600;\n color: var(--sg-fg, #0f172a);\n }\n .td-notif-detail {\n font-size: 11.5px;\n color: var(--sg-muted, #64748b);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n .td-hint {\n font-size: 11.5px;\n color: var(--sg-muted, #64748b);\n }\n .td-hint strong {\n color: var(--sg-fg, #1e293b);\n font-weight: 600;\n }\n\n .td-chip {\n border: 1px solid var(--sg-border, #cbd5e1);\n background: var(--sg-bg, #ffffff);\n color: var(--sg-fg, #1e293b);\n border-radius: 999px;\n padding: 3px 10px;\n font-size: 11.5px;\n font-weight: 500;\n cursor: pointer;\n line-height: 1.6;\n transition:\n transform 80ms ease-out,\n box-shadow 120ms;\n }\n .td-chip:hover {\n transform: translateY(-1px);\n box-shadow: 0 2px 4px rgba(0, 0, 0, 0.07);\n }\n .td-chip-active {\n border-color: transparent;\n box-shadow: 0 0 0 2px var(--sg-accent, #2563eb) inset;\n font-weight: 600;\n }\n .td-chip-count {\n margin-left: 6px;\n font-size: 10px;\n color: var(--sg-muted, #64748b);\n font-variant-numeric: tabular-nums;\n }\n\n /* ─── Sector tints (chips + inline pills) ─────────────────────── */\n :global(.td-sector) {\n display: inline-block;\n padding: 2px 10px;\n border-radius: 999px;\n font-size: 13px;\n font-weight: 600;\n line-height: 1.5;\n white-space: nowrap;\n }\n :global(.td-sec-technology) {\n background: #dbeafe;\n color: #1d4ed8;\n }\n :global(.td-sec-financials) {\n background: #dcfce7;\n color: #166534;\n }\n :global(.td-sec-healthcare) {\n background: #fce7f3;\n color: #9d174d;\n }\n :global(.td-sec-energy) {\n background: #fef3c7;\n color: #92400e;\n }\n :global(.td-sec-consumer) {\n background: #ede9fe;\n color: #5b21b6;\n }\n :global(.td-sec-industrials) {\n background: #e2e8f0;\n color: #334155;\n }\n :global(.td-sec-materials) {\n background: #fee2e2;\n color: #b91c1c;\n }\n :global(.td-sec-utilities) {\n background: #ccfbf1;\n color: #115e59;\n }\n :global(.td-sec-communication) {\n background: #e0e7ff;\n color: #3730a3;\n }\n :global(.td-sec-real-estate) {\n background: #ffedd5;\n color: #9a3412;\n }\n :global([data-theme=\"dark\"] .td-sec-technology) {\n background: rgba(59, 130, 246, 0.18);\n color: #93c5fd;\n }\n :global([data-theme=\"dark\"] .td-sec-financials) {\n background: rgba(34, 197, 94, 0.18);\n color: #4ade80;\n }\n :global([data-theme=\"dark\"] .td-sec-healthcare) {\n background: rgba(236, 72, 153, 0.18);\n color: #f9a8d4;\n }\n :global([data-theme=\"dark\"] .td-sec-energy) {\n background: rgba(245, 158, 11, 0.18);\n color: #fbbf24;\n }\n :global([data-theme=\"dark\"] .td-sec-consumer) {\n background: rgba(139, 92, 246, 0.18);\n color: #c4b5fd;\n }\n :global([data-theme=\"dark\"] .td-sec-industrials) {\n background: rgba(148, 163, 184, 0.18);\n color: #cbd5e1;\n }\n :global([data-theme=\"dark\"] .td-sec-materials) {\n background: rgba(239, 68, 68, 0.18);\n color: #fca5a5;\n }\n :global([data-theme=\"dark\"] .td-sec-utilities) {\n background: rgba(20, 184, 166, 0.18);\n color: #5eead4;\n }\n :global([data-theme=\"dark\"] .td-sec-communication) {\n background: rgba(99, 102, 241, 0.18);\n color: #a5b4fc;\n }\n :global([data-theme=\"dark\"] .td-sec-real-estate) {\n background: rgba(249, 115, 22, 0.18);\n color: #fdba74;\n }\n\n /* ─── Symbol cell ─────────────────────────────────────────────── */\n :global(.td-symbol) {\n display: inline-flex;\n align-items: center;\n gap: 9px;\n width: 100%;\n }\n :global(.td-logo) {\n flex-shrink: 0;\n /* A shape-following shadow so circular marks don't get a square halo. */\n filter: drop-shadow(0 1px 1.5px rgba(15, 23, 42, 0.3));\n }\n\n :global(.td-sym-meta) {\n display: inline-flex;\n flex-direction: column;\n min-width: 0;\n line-height: 1.15;\n }\n :global(.td-sym-ticker) {\n font-family: ui-monospace, SFMono-Regular, \"JetBrains Mono\", Menlo,\n monospace;\n font-weight: 700;\n letter-spacing: 0.03em;\n color: var(--sg-fg, #0f172a);\n font-size: 13px;\n }\n :global(.td-sym-exch) {\n font-size: 9.5px;\n font-weight: 600;\n letter-spacing: 0.06em;\n color: var(--sg-muted, #64748b);\n text-transform: uppercase;\n }\n\n /* ─── Pulse + change tints ────────────────────────────────────── */\n :global(.td-tick) {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n padding: 0 3px;\n border-radius: 3px;\n font-variant-numeric: tabular-nums;\n }\n :global(.td-up) {\n color: #16a34a;\n font-weight: 600;\n }\n :global(.td-down) {\n color: #dc2626;\n font-weight: 600;\n }\n :global([data-theme=\"dark\"] .td-up) {\n color: #4ade80;\n }\n :global([data-theme=\"dark\"] .td-down) {\n color: #f87171;\n }\n\n :global(.td-vol) {\n color: var(--sg-muted, #64748b);\n }\n\n :global(.td-pulse-up) {\n animation: td-pulse-up 320ms ease-out;\n }\n :global(.td-pulse-down) {\n animation: td-pulse-down 320ms ease-out;\n }\n @keyframes td-pulse-up {\n 0% {\n background: rgba(34, 197, 94, 0.55);\n }\n 100% {\n background: transparent;\n }\n }\n @keyframes td-pulse-down {\n 0% {\n background: rgba(239, 68, 68, 0.55);\n }\n 100% {\n background: transparent;\n }\n }\n\n /* ─── Sparkline ───────────────────────────────────────────────── */\n /* Direction colours live as theme-aware CSS vars on the cell, and the SVG\n reads them inline (see TrendCell). Setting the colour inline makes the\n trail reliably green/red in every context - including the home-page\n preview - instead of falling back to a neutral slate that looks blue. */\n :global(.td-trend) {\n display: flex;\n align-items: center;\n gap: 8px;\n width: 100%;\n height: 100%;\n --td-spark-up: #16a34a;\n --td-spark-down: #dc2626;\n }\n :global([data-theme=\"dark\"] .td-trend) {\n --td-spark-up: #4ade80;\n --td-spark-down: #f87171;\n }\n :global(.td-spark) {\n flex: 1 1 auto;\n min-width: 0;\n height: 68%;\n max-height: 30px;\n color: var(--sg-muted, #64748b);\n }\n :global(.td-spark-pct) {\n flex: 0 0 auto;\n width: 48px;\n text-align: right;\n font-size: 11.5px;\n font-weight: 600;\n font-variant-numeric: tabular-nums;\n }\n\n /* ─── Position bar ────────────────────────────────────────────── */\n :global(.td-pos) {\n display: flex;\n align-items: center;\n gap: 8px;\n min-width: 0;\n }\n :global(.td-pos-bar) {\n position: relative;\n flex: 1 1 auto;\n height: 6px;\n background: var(--sg-border, #e2e8f0);\n border-radius: 3px;\n overflow: hidden;\n }\n :global(.td-pos-fill) {\n position: absolute;\n inset: 0 auto 0 0;\n transition: width 500ms ease-out;\n }\n :global(.td-pos-cool) {\n background: #2563eb;\n }\n :global(.td-pos-warm) {\n background: #ca8a04;\n }\n :global(.td-pos-hot) {\n background: #dc2626;\n }\n :global([data-theme=\"dark\"] .td-pos-cool) {\n background: #60a5fa;\n }\n :global([data-theme=\"dark\"] .td-pos-warm) {\n background: #fbbf24;\n }\n :global([data-theme=\"dark\"] .td-pos-hot) {\n background: #f87171;\n }\n :global(.td-pos-text) {\n font-size: 11px;\n color: var(--sg-muted, #64748b);\n font-variant-numeric: tabular-nums;\n min-width: 28px;\n text-align: right;\n }\n\n /* ─── Risk pill ───────────────────────────────────────────────── */\n :global(.td-risk) {\n display: inline-block;\n padding: 2px 10px;\n border-radius: 4px;\n font-size: 13px;\n font-weight: 600;\n line-height: 1.5;\n }\n :global(.td-risk-low) {\n background: #dcfce7;\n color: #166534;\n }\n :global(.td-risk-med) {\n background: #fef3c7;\n color: #92400e;\n }\n :global(.td-risk-high) {\n background: #fee2e2;\n color: #b91c1c;\n }\n :global([data-theme=\"dark\"] .td-risk-low) {\n background: rgba(34, 197, 94, 0.18);\n color: #4ade80;\n }\n :global([data-theme=\"dark\"] .td-risk-med) {\n background: rgba(245, 158, 11, 0.18);\n color: #fbbf24;\n }\n :global([data-theme=\"dark\"] .td-risk-high) {\n background: rgba(239, 68, 68, 0.18);\n color: #f87171;\n }\n\n /* ─── P&L ─────────────────────────────────────────────────────── */\n :global(.td-pnl) {\n font-weight: 700;\n }\n\n /* Phones, portrait and landscape: the notification badge hangs 6px outside\n its bell button (`right: -6px`), and on a phone that button sits flush\n with the right edge of the demo stage (in portrait the spacer pushes the\n hint / Pause / bell line to the right once the chips take a line of their\n own) - so the badge was the one thing on this demo getting clipped. Tuck\n it inside the button; it still reads as a badge. */\n @media (max-width: 639px), (max-height: 500px) and (pointer: coarse),\n (min-width: 768px) and (max-width: 1023px) and (pointer: coarse) {\n .td-bell-badge {\n top: -4px;\n right: 0;\n }\n }\n\n /* Phone: the chrome above the grid has to be cheap, or the grid pays for\n it - the KPI strip and toolbar are shrink-0 and the grid pane is flex-1.\n The shared mobile layer makes the strip 2-up (three rows of these cards)\n and gives every button a 40px touch target, which stacked ~480px of KPIs\n and wrapped chips above the grid on a 384px-wide phone and squeezed the\n grid to nothing. Compact cards and a single scrolling chip row bring\n that to ~300px. */\n @media (max-width: 639px) {\n .td-kpi-strip {\n gap: 6px;\n }\n .td-kpi {\n padding: 8px 10px;\n border-radius: 8px;\n }\n .td-kpi-label {\n font-size: 10px;\n margin-bottom: 3px;\n }\n .td-kpi-value {\n font-size: 17px;\n }\n .td-kpi-foot {\n margin-top: 3px;\n font-size: 10.5px;\n }\n /* One row that pans instead of three that wrap. `flex: 1 1 100%` takes\n the toolbar's whole first line, so the hint, Pause and the bell drop to\n a second line together. */\n .td-chips {\n display: flex;\n flex: 1 1 100%;\n flex-wrap: nowrap;\n gap: 6px;\n overflow-x: auto;\n overscroll-behavior-x: contain;\n -webkit-overflow-scrolling: touch;\n scrollbar-width: none;\n }\n .td-chips::-webkit-scrollbar {\n display: none;\n }\n .td-chip {\n flex: 0 0 auto;\n }\n }\n</style>\n"
8
+ "source": "<script lang=\"ts\">\n /**\n * 00. Trading desk - hero demo\n * ----------------------------\n * The first demo a prospect sees. Ten thousand live securities with\n * sparklines, sector chips, pinned P&L, and a 500 ms tick stream that\n * flashes cells on price moves. Built on the same SvGrid primitives as\n * every other demo - no special hero APIs.\n *\n * Highlights:\n * - Row virtualization keeps 10,000 rows scrolling at 60 fps\n * - Symbol pinned LEFT, P&L pinned RIGHT (sticky horizontal scroll)\n * - Live tick: ~5 % of rows update every 500 ms, cells pulse green/red\n * - Sparklines re-rendered from each row's rolling 30-point buffer\n * - KPI strip recomputes across all 10k rows on every tick via $derived\n * - Sector filter chips above the grid drive a $derived row slice\n *\n * Implementation notes:\n * - Rows live in `$state.raw` so the grid sees one new array reference\n * per tick instead of 10k per-cell mutations\n * - The flash direction rides on the ticked row itself (`pulse`,\n * `pulseAt`), so a tick re-renders only the rows that moved. A shared\n * pulse map read by every cell re-ran every visible cell twice a\n * second and made scrolling stutter on each tick\n * - Sparkline path is recomputed only when the row reference changes\n * (i.e. only ticked rows), so static rows pay no SVG cost\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n renderSnippet,\n type ColumnDef,\n } from \"@svgrid/grid\";\n\n type Sector =\n | \"Technology\"\n | \"Financials\"\n | \"Healthcare\"\n | \"Energy\"\n | \"Consumer\"\n | \"Industrials\"\n | \"Materials\"\n | \"Utilities\"\n | \"Communication\"\n | \"Real Estate\";\n\n type RiskTier = \"Low\" | \"Med\" | \"High\";\n\n type Security = {\n symbol: string;\n name: string;\n sector: Sector;\n last: number;\n open: number;\n change: number;\n pctChange: number;\n bid: number;\n ask: number;\n spread: number;\n volume: number;\n history: number[]; // rolling 30-point trail used for the sparkline\n position: number; // current shares held\n positionCap: number; // risk cap in shares (for the % bar)\n pnl: number; // mark-to-market P&L in USD\n risk: RiskTier;\n pulse?: \"up\" | \"down\"; // direction of the last tick that moved this row\n pulseAt?: number; // tick number of that move\n };\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature });\n\n // ---- Deterministic PRNG so the seed is identical every load. Same\n // pattern as 20-industrial-dashboard so screenshots are reproducible.\n let prng = 0xc0ffee15;\n function rand(): number {\n prng = (prng * 1664525 + 1013904223) >>> 0;\n return prng / 0xffffffff;\n }\n function pick<T>(arr: readonly T[]): T {\n return arr[Math.floor(rand() * arr.length)]!;\n }\n function round(n: number, p = 2): number {\n const m = 10 ** p;\n return Math.round(n * m) / m;\n }\n\n // Give every symbol its OWN brand colour + emblem (deterministic from the\n // ticker), so the logo tiles read like distinct real company marks instead of\n // ten repeated sector tints. A curated palette of vivid brand colours (not raw\n // HSL, which turns muddy at low lightness) keeps them crisp and varied.\n const EXCHANGES = [\"NASDAQ\", \"NYSE\", \"LSE\", \"TSX\", \"XETRA\", \"HKEX\"] as const;\n // Vivid, visually distinct base colours - the kind real logos use.\n const BRAND_COLORS = [\n \"#4f46e5\", \"#2563eb\", \"#0ea5e9\", \"#0891b2\", \"#0d9488\", \"#059669\",\n \"#16a34a\", \"#ca8a04\", \"#ea580c\", \"#dc2626\", \"#e11d48\", \"#db2777\",\n \"#c026d3\", \"#9333ea\", \"#7c3aed\", \"#0284c7\", \"#334155\",\n ] as const;\n function symbolHash(sym: string): number {\n let h = 2166136261;\n for (let i = 0; i < sym.length; i++) {\n h ^= sym.charCodeAt(i);\n h = Math.imul(h, 16777619);\n }\n return h >>> 0;\n }\n function brandColor(sym: string): string {\n return BRAND_COLORS[symbolHash(sym) % BRAND_COLORS.length]!;\n }\n function companyInitials(name: string, fallback: string): string {\n const parts = name.split(/\\s+/).filter(Boolean);\n const ini = parts.slice(0, 2).map((w) => w[0] ?? \"\").join(\"\");\n return (ini || fallback.slice(0, 2)).toUpperCase();\n }\n\n const SECTORS: readonly Sector[] = [\n \"Technology\",\n \"Financials\",\n \"Healthcare\",\n \"Energy\",\n \"Consumer\",\n \"Industrials\",\n \"Materials\",\n \"Utilities\",\n \"Communication\",\n \"Real Estate\",\n ];\n\n // Per-sector accent, matching the inline chip palette. Fed to the list editor\n // as `editorOptions` so the dropdown paints each choice as a coloured pill.\n const SECTOR_COLORS: Record<Sector, string> = {\n Technology: \"#2563eb\",\n Financials: \"#16a34a\",\n Healthcare: \"#db2777\",\n Energy: \"#d97706\",\n Consumer: \"#7c3aed\",\n Industrials: \"#475569\",\n Materials: \"#dc2626\",\n Utilities: \"#0d9488\",\n Communication: \"#4f46e5\",\n \"Real Estate\": \"#ea580c\",\n };\n const SECTOR_OPTIONS = SECTORS.map((s) => ({\n value: s,\n label: s,\n color: SECTOR_COLORS[s],\n }));\n const RISK_OPTIONS = [\n { value: \"Low\", label: \"Low\", color: \"#16a34a\" },\n { value: \"Med\", label: \"Med\", color: \"#d97706\" },\n { value: \"High\", label: \"High\", color: \"#dc2626\" },\n ];\n\n // Fictional but real-sounding companies. A large first x descriptor x type\n // space so 10,000 rows rarely repeat a name, and the ticker is an abbreviation\n // of that name (deduped to stay unique) the way a real listing symbol is - so\n // you get BEAC / VRTX / NRDC, not AAA / AAB / AAC.\n const FIRST_WORDS = [\n \"Nordic\", \"Pacific\", \"Atlas\", \"Vertex\", \"Quantum\", \"Helios\", \"Stellar\", \"Apex\",\n \"Crescent\", \"Sigma\", \"Pioneer\", \"Aurora\", \"Granite\", \"Cobalt\", \"Hyperion\", \"Meridian\",\n \"Polaris\", \"Sentinel\", \"Tessera\", \"Vantage\", \"Cascade\", \"Frontier\", \"Lumen\", \"Beacon\",\n \"Cardinal\", \"Orion\", \"Solstice\", \"Summit\", \"Ironwood\", \"Redwood\", \"Silverline\", \"Keystone\",\n \"Northwind\", \"Brightwater\", \"Copper\", \"Everest\", \"Halcyon\", \"Kestrel\", \"Nimbus\", \"Onyx\",\n \"Pinnacle\", \"Rampart\", \"Seabright\", \"Thornton\", \"Vega\", \"Westford\", \"Ashford\", \"Belmont\",\n \"Corvus\", \"Delphi\", \"Ember\", \"Fairmont\", \"Grayson\", \"Harborview\", \"Ridgeline\", \"Sable\",\n \"Trident\", \"Ridgewood\", \"Wexford\", \"Auric\", \"Blackpine\", \"Clearwater\", \"Dunmore\", \"Larkspur\",\n ];\n const DESCRIPTORS = [\n \"Bio\", \"Energy\", \"Materials\", \"Logistics\", \"Dynamics\", \"Robotics\", \"Analytics\",\n \"Semiconductor\", \"Pharma\", \"Mining\", \"Aerospace\", \"Digital\", \"Capital\", \"Metals\",\n \"Renewables\", \"Health\", \"Foods\", \"Grid\", \"Cloud\", \"Data\", \"Motors\", \"Chemical\",\n \"Petroleum\", \"Software\", \"Devices\", \"Textiles\", \"Beverage\", \"Freight\", \"Payments\", \"Media\",\n \"Security\", \"Instruments\", \"Optics\", \"Marine\", \"Storage\", \"Gaming\", \"Networks\", \"Systems\",\n ];\n const CORP_TYPES = [\n \"Holdings\", \"Group\", \"Partners\", \"Corp\", \"Inc\", \"Industries\", \"Technologies\", \"Labs\",\n \"Ventures\", \"Trust\", \"Global\", \"Enterprises\", \"Solutions\", \"Company\", \"International\", \"Resources\",\n ];\n\n /** A mostly-unique, real-sounding company name as its component words. */\n function makeName(usedNames: Set<string>): string[] {\n for (let t = 0; t < 12; t++) {\n const first = pick(FIRST_WORDS);\n const roll = rand();\n const words =\n roll < 0.45\n ? [first, pick(DESCRIPTORS), pick(CORP_TYPES)]\n : roll < 0.75\n ? [first, pick(DESCRIPTORS)]\n : [first, pick(CORP_TYPES)];\n const key = words.join(\" \");\n if (!usedNames.has(key)) {\n usedNames.add(key);\n return words;\n }\n }\n const words = [pick(FIRST_WORDS), pick(DESCRIPTORS), pick(CORP_TYPES)];\n usedNames.add(words.join(\" \"));\n return words;\n }\n\n /** An abbreviation-style ticker for a name, unique across the seed. */\n function makeTicker(words: string[], used: Set<string>): string {\n const parts = words.map((w) => w.toUpperCase().replace(/[^A-Z]/g, \"\"));\n const f = parts[0] ?? \"CO\";\n const s = parts[1] ?? \"\";\n const th = parts[2] ?? \"\";\n const candidates = [\n f.slice(0, 4),\n f.slice(0, 3),\n f.slice(0, 2) + s.slice(0, 2),\n f.slice(0, 3) + s.slice(0, 1),\n f.slice(0, 1) + s.slice(0, 1) + th.slice(0, 1),\n f.slice(0, 2) + s.slice(0, 1) + th.slice(0, 1),\n ];\n for (const c of candidates) {\n if (c.length >= 3 && c.length <= 4 && !used.has(c)) {\n used.add(c);\n return c;\n }\n }\n const base = f.slice(0, 3) || \"COR\";\n for (let k = 0; k < 26; k++) {\n const c = base + String.fromCharCode(65 + k);\n if (!used.has(c)) {\n used.add(c);\n return c;\n }\n }\n let n = 1;\n while (used.has(`X${n}`)) n++;\n used.add(`X${n}`);\n return `X${n}`;\n }\n\n function makeRow(\n i: number,\n usedNames: Set<string>,\n usedTickers: Set<string>,\n ): Security {\n const words = makeName(usedNames);\n const name = words.join(\" \");\n const symbol = makeTicker(words, usedTickers);\n const sector = SECTORS[i % SECTORS.length]!;\n // Price range tuned so big-cap-ish names live near the top of the seed.\n const basePrice = round(8 + rand() * 990, 2);\n const open = basePrice;\n // Pre-seed the 30-point trail. Each name gets its OWN trend + volatility so\n // the sparklines read like distinct real charts - steady climbers, choppy\n // names, sliders - instead of identical noise. Multiplicative walk with a\n // per-name drift and a per-name step size.\n const trend = round((rand() - 0.5) * 0.5, 4); // total drift over the trail: -25%..+25%\n const vol = 0.005 + rand() * 0.018; // per-step volatility 0.5%..2.3%\n const history: number[] = [];\n let h = basePrice;\n for (let k = 0; k < 30; k++) {\n h = Math.max(0.5, h * (1 + trend / 30 + (rand() - 0.5) * vol));\n history.push(round(h, 2));\n }\n const last = history[history.length - 1]!;\n const change = round(last - open, 2);\n const pctChange = round((change / open) * 100, 2);\n const spread = Math.max(round(last * 0.0006, 2), 0.01);\n const positionCap = 500 + Math.floor(rand() * 9500);\n const position = Math.floor(positionCap * (0.1 + rand() * 0.85));\n const riskRoll = rand();\n const risk: RiskTier =\n riskRoll < 0.55 ? \"Low\" : riskRoll < 0.88 ? \"Med\" : \"High\";\n return {\n symbol,\n name,\n sector,\n last,\n open,\n change,\n pctChange,\n bid: round(last - spread, 2),\n ask: round(last + spread, 2),\n spread,\n volume: Math.floor(50_000 + rand() * 5_000_000),\n history,\n position,\n positionCap,\n pnl: round(position * change, 0),\n risk,\n };\n }\n\n function makeSeed(n: number): Security[] {\n const usedNames = new Set<string>();\n const usedTickers = new Set<string>();\n const out: Security[] = new Array(n);\n for (let i = 0; i < n; i++) out[i] = makeRow(i, usedNames, usedTickers);\n return out;\n }\n\n // ---- State\n\n const ROW_COUNT = 10_000;\n let rows = $state.raw<Security[]>(makeSeed(ROW_COUNT));\n // The current tick number, deliberately NOT reactive: a cell reads it only\n // when its own row changed (or it scrolls into view), so it can tell a fresh\n // move from an old one without every cell subscribing to the tick counter.\n let pulseTick = 0;\n let paused = $state(false);\n let ticks = $state(0);\n let sectorFilter = $state<Sector | \"All\">(\"All\");\n\n // ---- Notifications: a lightweight desk feed. The tick loop surfaces the odd\n // notable move as a bell entry (throttled so it stays an occasional\n // heads-up, never a stream). Purely local demo state - no engine, no watching.\n type Notif = {\n id: number;\n symbol: string;\n tone: \"up\" | \"down\";\n title: string;\n detail: string;\n };\n let notifications = $state<Notif[]>([]);\n let unread = $state(0);\n let bellOpen = $state(false);\n let notifSeq = 0;\n let lastNotifTick = -100;\n const NOTIF_GAP = 6; // min ticks between notifications (~3 s at 500 ms)\n const NOTIF_PCT = 10; // only flag names at least this far from open\n\n function pushNotif(top: Security) {\n lastNotifTick = ticks;\n const up = top.pctChange >= 0;\n const n: Notif = {\n id: ++notifSeq,\n symbol: top.symbol,\n tone: up ? \"up\" : \"down\",\n title: `${top.symbol} ${up ? \"+\" : \"\"}${top.pctChange.toFixed(1)}% today`,\n detail: `${top.name} · ${fmtUsd(top.last)} · ${top.risk} risk`,\n };\n notifications = [n, ...notifications].slice(0, 24);\n unread += 1;\n }\n\n function toggleBell() {\n bellOpen = !bellOpen;\n if (bellOpen) unread = 0;\n }\n function clearNotifs() {\n notifications = [];\n unread = 0;\n }\n\n // Close the bell dropdown on an outside click.\n $effect(() => {\n if (!bellOpen) return;\n const onDown = (e: MouseEvent) => {\n const t = e.target;\n if (t instanceof Element && t.closest(\".td-notif\")) return;\n bellOpen = false;\n };\n window.addEventListener(\"mousedown\", onDown);\n return () => window.removeEventListener(\"mousedown\", onDown);\n });\n\n function tick() {\n pulseTick += 1;\n // Update ~5 % of rows per tick. Touching every row each tick is\n // expensive AND distracting visually - 500 active rows per tick is\n // the sweet spot for \"feels alive but readable\".\n const touchCount = Math.floor(ROW_COUNT * 0.05);\n const touchSet = new Set<number>();\n while (touchSet.size < touchCount) {\n touchSet.add(Math.floor(rand() * ROW_COUNT));\n }\n\n const nextRows = rows.slice();\n // Track the most extreme mover among the rows that ticked, for the desk feed.\n let topMover: Security | null = null;\n for (const idx of touchSet) {\n const row = rows[idx]!;\n const drift = (rand() - 0.5) * row.last * 0.006; // up to ±0.3 %\n const newLast = Math.max(round(row.last + drift, 2), 0.01);\n if (newLast === row.last) continue;\n const direction: \"up\" | \"down\" = newLast > row.last ? \"up\" : \"down\";\n const change = round(newLast - row.open, 2);\n const pctChange = round((change / row.open) * 100, 2);\n const spread = Math.max(round(newLast * 0.0006, 2), 0.01);\n const history = row.history.slice(1);\n history.push(newLast);\n const next: Security = {\n ...row,\n last: newLast,\n change,\n pctChange,\n bid: round(newLast - spread, 2),\n ask: round(newLast + spread, 2),\n spread,\n volume: row.volume + Math.floor(50 + rand() * 4_000),\n history,\n pnl: round(row.position * change, 0),\n pulse: direction,\n pulseAt: pulseTick,\n };\n nextRows[idx] = next;\n if (!topMover || Math.abs(next.pctChange) > Math.abs(topMover.pctChange)) {\n topMover = next;\n }\n }\n rows = nextRows;\n ticks += 1;\n // Occasionally surface the standout mover as a bell notification.\n if (\n topMover &&\n Math.abs(topMover.pctChange) >= NOTIF_PCT &&\n ticks - lastNotifTick >= NOTIF_GAP\n ) {\n pushNotif(topMover);\n }\n }\n\n $effect(() => {\n if (paused) return;\n const id = setInterval(tick, 500);\n return () => clearInterval(id);\n });\n\n // Flash only on the tick that moved the row. A row that moved earlier and\n // scrolls into view now mounts without the class, so scrolling does not\n // light up every row that ever ticked. The price cells wrap their span in\n // `{#key row.pulseAt}` so a second move in the same direction replays the\n // animation instead of leaving an unchanged class in place.\n function pulseClass(row: Security): \"td-pulse-up\" | \"td-pulse-down\" | \"\" {\n if (row.pulseAt !== pulseTick) return \"\";\n return row.pulse === \"up\" ? \"td-pulse-up\" : \"td-pulse-down\";\n }\n\n // Pipe user edits back into the parent `rows` state. Without this, every\n // 500 ms tick replaces `rows` with a fresh array (and the grid re-syncs\n // its internalData + drops `editedCellValues`) - wiping the user's input.\n // We also recompute pnl on a position change so the value the user just\n // typed shows updated P&L immediately, not on the next price tick.\n function onCellValueChange(args: {\n rowIndex: number;\n columnId: string;\n newValue: unknown;\n }): void {\n const visible = visibleRows;\n const target = visible[args.rowIndex];\n if (!target) return;\n const sourceIdx = rows.indexOf(target);\n if (sourceIdx < 0) return;\n const updated: Security = { ...target };\n if (args.columnId === \"position\") {\n const n = Number(args.newValue);\n if (!Number.isFinite(n)) return;\n updated.position = Math.max(0, Math.floor(n));\n updated.pnl = round(updated.position * updated.change, 0);\n } else if (args.columnId === \"risk\") {\n const v = String(args.newValue);\n if (v !== \"Low\" && v !== \"Med\" && v !== \"High\") return;\n updated.risk = v as RiskTier;\n } else if (args.columnId === \"sector\") {\n const v = String(args.newValue);\n if (!SECTORS.includes(v as Sector)) return;\n updated.sector = v as Sector;\n } else {\n return;\n }\n const next = rows.slice();\n next[sourceIdx] = updated;\n rows = next;\n }\n\n // ---- Derived: filtered rows + KPI strip\n const visibleRows = $derived(\n sectorFilter === \"All\"\n ? rows\n : rows.filter((r) => r.sector === sectorFilter),\n );\n\n const kpis = $derived.by(() => {\n let pnl = 0;\n let exposure = 0;\n let volume = 0;\n let spreadSum = 0;\n let advancers = 0;\n let decliners = 0;\n for (const r of visibleRows) {\n pnl += r.pnl;\n exposure += r.position * r.last;\n volume += r.volume;\n spreadSum += r.spread;\n if (r.change > 0) advancers += 1;\n else if (r.change < 0) decliners += 1;\n }\n const avgSpread = visibleRows.length ? spreadSum / visibleRows.length : 0;\n return {\n pnl,\n exposure,\n volume,\n avgSpread,\n advancers,\n decliners,\n count: visibleRows.length,\n };\n });\n\n // ---- Sparkline helpers\n\n function sparkPath(history: number[], w: number, h: number): string {\n const min = Math.min(...history);\n const max = Math.max(...history);\n const range = Math.max(1e-6, max - min);\n const step = w / (history.length - 1);\n // Vertical padding so the line never sits flush against the top/bottom edge -\n // reads cleaner and lets a nearly-flat series actually look flat.\n const pad = h * 0.16;\n const inner = h - pad * 2;\n let d = \"\";\n for (let i = 0; i < history.length; i++) {\n const x = round(i * step, 2);\n const y = round(pad + (1 - (history[i]! - min) / range) * inner, 2);\n d += `${i === 0 ? \"M\" : \"L\"}${x} ${y} `;\n }\n return d.trim();\n }\n\n // ---- Formatters\n // One formatter, built once. `toLocaleString` with options builds a new\n // Intl.NumberFormat on every call, which showed up in the scroll profile:\n // every Last cell that scrolls into view formats a price.\n const USD = new Intl.NumberFormat(\"en-US\", {\n style: \"currency\",\n currency: \"USD\",\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n });\n function fmtUsd(n: number): string {\n return USD.format(n);\n }\n function fmtUsdShort(n: number): string {\n const abs = Math.abs(n);\n if (abs >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)} B`;\n if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(2)} M`;\n if (abs >= 1_000) return `${(n / 1_000).toFixed(1)} K`;\n return n.toFixed(0);\n }\n function fmtPnl(n: number): string {\n const sign = n >= 0 ? \"+\" : \"-\";\n return `${sign}${fmtUsdShort(Math.abs(n))}`;\n }\n</script>\n\n<!-- ───────────────────────── CELL SNIPPETS ───────────────────────── -->\n\n{#snippet LogoMark(m: { sym: string; name: string })}\n {@const c = brandColor(m.sym)}\n {@const style = symbolHash(m.sym) % 6}\n {@const roundTile = symbolHash(m.sym) % 2 === 0}\n {@const initials = companyInitials(m.name, m.sym)}\n <svg class=\"td-logo\" viewBox=\"0 0 28 28\" width=\"27\" height=\"27\" aria-hidden=\"true\">\n <!-- Brand tile: per-company colour, alternating rounded-square / disc, with a\n soft top sheen + darker foot so it reads like a moulded logo chip. -->\n {#if roundTile}\n <circle cx=\"14\" cy=\"14\" r=\"13\" fill={c} />\n <path d=\"M2 12 A13 13 0 0 1 26 12 L26 3 L2 3 Z\" fill=\"#fff\" opacity=\"0.16\" />\n <path d=\"M2 17 A13 13 0 0 0 26 17 L26 25 L2 25 Z\" fill=\"#000\" opacity=\"0.12\" />\n {:else}\n <rect x=\"1\" y=\"1\" width=\"26\" height=\"26\" rx=\"7.5\" fill={c} />\n <rect x=\"1\" y=\"1\" width=\"26\" height=\"12\" rx=\"7.5\" fill=\"#fff\" opacity=\"0.16\" />\n <rect x=\"1\" y=\"16\" width=\"26\" height=\"11\" rx=\"7.5\" fill=\"#000\" opacity=\"0.1\" />\n {/if}\n <!-- Mark: a distinct emblem per company (deterministic from the ticker). -->\n {#if style === 0}\n <circle cx=\"14\" cy=\"14\" r=\"6\" fill=\"none\" stroke=\"#fff\" stroke-width=\"2.2\" opacity=\"0.92\" />\n <circle cx=\"14\" cy=\"14\" r=\"1.9\" fill=\"#fff\" />\n {:else if style === 1}\n <path d=\"M9 18.5 L13.5 9.5 M14.5 18.5 L19 9.5\" stroke=\"#fff\" stroke-width=\"2.3\" stroke-linecap=\"round\" opacity=\"0.92\" />\n {:else if style === 2}\n <rect x=\"8\" y=\"15\" width=\"3.1\" height=\"5\" rx=\"1\" fill=\"#fff\" opacity=\"0.92\" />\n <rect x=\"12.45\" y=\"11.5\" width=\"3.1\" height=\"8.5\" rx=\"1\" fill=\"#fff\" opacity=\"0.92\" />\n <rect x=\"16.9\" y=\"8\" width=\"3.1\" height=\"12\" rx=\"1\" fill=\"#fff\" opacity=\"0.92\" />\n {:else if style === 3}\n <path d=\"M14 8 L20 19 L8 19 Z\" fill=\"#fff\" opacity=\"0.92\" />\n {:else if style === 4}\n <circle cx=\"11.6\" cy=\"14\" r=\"4.8\" fill=\"#fff\" opacity=\"0.5\" />\n <circle cx=\"16.4\" cy=\"14\" r=\"4.8\" fill=\"#fff\" opacity=\"0.5\" />\n {:else}\n <text\n x=\"14\"\n y=\"18.4\"\n text-anchor=\"middle\"\n font-size=\"11\"\n font-weight=\"800\"\n fill=\"#fff\"\n font-family=\"ui-monospace, SFMono-Regular, Menlo, monospace\">{initials}</text>\n {/if}\n </svg>\n{/snippet}\n\n{#snippet SymbolCell(props: { row: Security })}\n {@const exchange = EXCHANGES[symbolHash(props.row.symbol) % EXCHANGES.length]}\n <span class=\"td-symbol\">\n {@render LogoMark({ sym: props.row.symbol, name: props.row.name })}\n <span class=\"td-sym-meta\">\n <span class=\"td-sym-ticker\">{props.row.symbol}</span>\n <span class=\"td-sym-exch\">{exchange}</span>\n </span>\n </span>\n{/snippet}\n\n{#snippet SectorChip(props: { row: Security })}\n <span\n class=\"td-sector td-sec-{props.row.sector\n .toLowerCase()\n .replace(/\\s+/g, '-')}\"\n >\n {props.row.sector}\n </span>\n{/snippet}\n\n{#snippet LastCell(props: { row: Security })}\n {#key props.row.pulseAt}\n <span class={`td-tick ${pulseClass(props.row)}`}>\n {fmtUsd(props.row.last)}\n </span>\n {/key}\n{/snippet}\n\n{#snippet ChangeCell(props: { row: Security })}\n {@const positive = props.row.change >= 0}\n {#key props.row.pulseAt}\n <span\n class={`td-tick ${pulseClass(props.row)} ${positive ? \"td-up\" : \"td-down\"}`}\n >\n <svg viewBox=\"0 0 12 12\" width=\"9\" height=\"9\" aria-hidden=\"true\">\n {#if positive}\n <path d=\"M6 2 L11 9 L1 9 Z\" fill=\"currentColor\" />\n {:else}\n <path d=\"M6 10 L1 3 L11 3 Z\" fill=\"currentColor\" />\n {/if}\n </svg>\n {Math.abs(props.row.change).toFixed(2)}\n </span>\n {/key}\n{/snippet}\n\n{#snippet PctChangeCell(props: { row: Security })}\n {@const positive = props.row.pctChange >= 0}\n {#key props.row.pulseAt}\n <span\n class={`td-tick ${pulseClass(props.row)} ${positive ? \"td-up\" : \"td-down\"}`}\n >\n {positive ? \"+\" : \"\"}{props.row.pctChange.toFixed(2)}%\n </span>\n {/key}\n{/snippet}\n\n{#snippet TrendCell(props: { row: Security })}\n {@const h = props.row.history}\n {@const positive = h[h.length - 1]! >= h[0]!}\n {@const pct = ((h[h.length - 1]! - h[0]!) / h[0]!) * 100}\n {@const line = sparkPath(h, 120, 32)}\n <span class=\"td-trend\">\n <svg\n class={`td-spark ${positive ? \"td-up\" : \"td-down\"}`}\n style={`color: ${positive ? \"var(--td-spark-up)\" : \"var(--td-spark-down)\"}`}\n viewBox=\"0 0 120 32\"\n preserveAspectRatio=\"none\"\n aria-hidden=\"true\"\n >\n <path\n d={`${line} L 120 32 L 0 32 Z`}\n fill=\"currentColor\"\n opacity=\"0.12\"\n />\n <path\n d={line}\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"1.6\"\n stroke-linejoin=\"round\"\n stroke-linecap=\"round\"\n vector-effect=\"non-scaling-stroke\"\n />\n </svg>\n <span class={`td-spark-pct ${positive ? \"td-up\" : \"td-down\"}`}>\n {pct >= 0 ? \"+\" : \"\"}{pct.toFixed(1)}%\n </span>\n </span>\n{/snippet}\n\n{#snippet BidCell(props: { row: Security })}\n {#key props.row.pulseAt}\n <span class={`td-tick ${pulseClass(props.row)}`}\n >{props.row.bid.toFixed(2)}</span\n >\n {/key}\n{/snippet}\n\n{#snippet AskCell(props: { row: Security })}\n {#key props.row.pulseAt}\n <span class={`td-tick ${pulseClass(props.row)}`}\n >{props.row.ask.toFixed(2)}</span\n >\n {/key}\n{/snippet}\n\n{#snippet SpreadCell(props: { row: Security })}\n {@const tight = props.row.spread < 0.05}\n {#key props.row.pulseAt}\n <span\n class={`td-tick ${pulseClass(props.row)} ${tight ? \"td-up\" : \"td-down\"}`}\n >\n {props.row.spread.toFixed(3)}\n </span>\n {/key}\n{/snippet}\n\n{#snippet VolumeCell(props: { row: Security })}\n {#key props.row.pulseAt}\n <span class={`td-tick ${pulseClass(props.row)} td-vol`}>\n {fmtUsdShort(props.row.volume)}\n </span>\n {/key}\n{/snippet}\n\n{#snippet PositionCell(props: { row: Security })}\n {@const pct = props.row.positionCap\n ? props.row.position / props.row.positionCap\n : 0}\n {@const tone = pct >= 0.85 ? \"hot\" : pct >= 0.6 ? \"warm\" : \"cool\"}\n <div class=\"td-pos\">\n <div class=\"td-pos-bar\">\n <div\n class={`td-pos-fill td-pos-${tone}`}\n style=\"width: {Math.min(100, pct * 100)}%\"\n ></div>\n </div>\n <span class=\"td-pos-text\">{Math.round(pct * 100)}%</span>\n </div>\n{/snippet}\n\n{#snippet RiskCell(props: { row: Security })}\n <span class=\"td-risk td-risk-{props.row.risk.toLowerCase()}\"\n >{props.row.risk}</span\n >\n{/snippet}\n\n{#snippet PnlCell(props: { row: Security })}\n {@const positive = props.row.pnl >= 0}\n {#key props.row.pulseAt}\n <span\n class={`td-tick td-pnl ${pulseClass(props.row)} ${positive ? \"td-up\" : \"td-down\"}`}\n >\n {fmtPnl(props.row.pnl)}\n </span>\n {/key}\n{/snippet}\n\n<!-- ───────────────────────── LAYOUT ───────────────────────── -->\n\n<section class=\"td-shell flex flex-col flex-1 min-h-0 gap-3\">\n <!-- KPI strip -->\n <div class=\"td-kpi-strip\">\n <div class=\"td-kpi\">\n <div class=\"td-kpi-label\">P&amp;L (book)</div>\n <div\n class={`td-kpi-value tabular-nums ${kpis.pnl >= 0 ? \"td-up\" : \"td-down\"}`}\n >\n {fmtPnl(kpis.pnl)}\n </div>\n <div class=\"td-kpi-foot\">\n <span class=\"td-up\">▲ {kpis.advancers.toLocaleString()}</span>\n &nbsp;·&nbsp;\n <span class=\"td-down\">▼ {kpis.decliners.toLocaleString()}</span>\n </div>\n </div>\n <div class=\"td-kpi\">\n <div class=\"td-kpi-label\">Gross exposure</div>\n <div class=\"td-kpi-value tabular-nums\">{fmtUsdShort(kpis.exposure)}</div>\n <div class=\"td-kpi-foot\">\n {kpis.count.toLocaleString()} active symbols\n </div>\n </div>\n <div class=\"td-kpi\">\n <div class=\"td-kpi-label\">Daily volume</div>\n <div class=\"td-kpi-value tabular-nums\">{fmtUsdShort(kpis.volume)}</div>\n <div class=\"td-kpi-foot\">shares traded</div>\n </div>\n <div class=\"td-kpi\">\n <div class=\"td-kpi-label\">Avg spread</div>\n <div class=\"td-kpi-value tabular-nums\">${kpis.avgSpread.toFixed(3)}</div>\n <div class=\"td-kpi-foot\">across visible book</div>\n </div>\n <div class=\"td-kpi\">\n <div class=\"td-kpi-label\">Tick stream</div>\n <div class=\"td-kpi-value tabular-nums\">{paused ? \"PAUSED\" : \"LIVE\"}</div>\n <div class=\"td-kpi-foot\">\n <span class={paused ? \"\" : \"td-pulse-dot\"}></span>\n {ticks.toLocaleString()} ticks · 500 ms\n </div>\n </div>\n </div>\n\n <!-- Toolbar: sector chips + pause -->\n <div class=\"td-toolbar\">\n <!-- `.td-chips` is `display: contents` on desktop, so the chips lay out in\n the toolbar exactly as they did before it existed; on a phone it turns\n into one horizontally scrolling row (see the phone block in <style>). -->\n <div class=\"td-chips\">\n <button\n type=\"button\"\n class=\"td-chip td-chip-all\"\n class:td-chip-active={sectorFilter === \"All\"}\n onclick={() => (sectorFilter = \"All\")}\n >All <span class=\"td-chip-count\">{rows.length.toLocaleString()}</span\n ></button\n >\n {#each SECTORS as s (s)}\n <button\n type=\"button\"\n class={`td-chip td-sec-${s.toLowerCase().replace(/\\s+/g, \"-\")}`}\n class:td-chip-active={sectorFilter === s}\n onclick={() => (sectorFilter = s)}>{s}</button\n >\n {/each}\n </div>\n <div class=\"td-toolbar-spacer\"></div>\n <span class=\"td-hint\">\n Editable: <strong>Position</strong> · <strong>Sector</strong> ·\n <strong>Risk</strong>\n </span>\n <button type=\"button\" class=\"td-btn\" onclick={() => (paused = !paused)}\n >{paused ? \"▶ Resume\" : \"⏸ Pause\"}</button\n >\n\n <!-- Notifications bell -->\n <div class=\"td-notif\">\n <button\n type=\"button\"\n class=\"td-bell\"\n class:td-bell-on={unread > 0}\n onclick={toggleBell}\n aria-label={`Notifications${unread ? ` (${unread} unread)` : \"\"}`}\n aria-expanded={bellOpen}\n >\n <svg\n viewBox=\"0 0 24 24\"\n width=\"17\"\n height=\"17\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"1.9\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9\" />\n <path d=\"M13.7 21a2 2 0 0 1-3.4 0\" />\n </svg>\n {#if unread > 0}\n <span class=\"td-bell-badge\">{unread > 9 ? \"9+\" : unread}</span>\n {/if}\n </button>\n\n {#if bellOpen}\n <div class=\"td-notif-panel\" role=\"dialog\" aria-label=\"Notifications\">\n <div class=\"td-notif-head\">\n <span>Notifications</span>\n {#if notifications.length}\n <button type=\"button\" class=\"td-notif-clear\" onclick={clearNotifs}\n >Clear</button\n >\n {/if}\n </div>\n {#if notifications.length === 0}\n <div class=\"td-notif-empty\">You're all caught up.</div>\n {:else}\n <ul class=\"td-notif-list\">\n {#each notifications as n (n.id)}\n <li class={`td-notif-item td-${n.tone}`}>\n <span class=\"td-notif-dot\" aria-hidden=\"true\"></span>\n <div class=\"td-notif-text\">\n <span class=\"td-notif-title\">{n.title}</span>\n <span class=\"td-notif-detail\">{n.detail}</span>\n </div>\n </li>\n {/each}\n </ul>\n {/if}\n </div>\n {/if}\n </div>\n </div>\n\n <!-- Grid -->\n <div class=\"flex-1 min-h-0\">\n <SvGrid\n columnResize\n data={visibleRows}\n columns={[\n {\n field: \"symbol\",\n header: \"Symbol\",\n width: 180,\n editable: false,\n cell: (ctx) => renderSnippet(SymbolCell, { row: ctx.row.original }),\n },\n { field: \"name\", header: \"Company\", width: 230, editable: false, hideBelow: 700 },\n {\n field: \"sector\",\n header: \"Sector\",\n width: 140,\n hideBelow: 700,\n editorType: \"list\",\n editorOptions: SECTOR_OPTIONS,\n cell: (ctx) => renderSnippet(SectorChip, { row: ctx.row.original }),\n },\n {\n field: \"last\",\n header: \"Last\",\n editorType: \"number\",\n width: 110,\n editable: false,\n cell: (ctx) => renderSnippet(LastCell, { row: ctx.row.original }),\n },\n {\n field: \"change\",\n header: \"Δ\",\n editorType: \"number\",\n width: 95,\n editable: false,\n cell: (ctx) => renderSnippet(ChangeCell, { row: ctx.row.original }),\n },\n {\n field: \"pctChange\",\n header: \"Δ %\",\n editorType: \"number\",\n width: 90,\n editable: false,\n cell: (ctx) =>\n renderSnippet(PctChangeCell, { row: ctx.row.original }),\n },\n {\n field: \"history\",\n header: \"Trend (30 ticks)\",\n width: 180,\n editable: false,\n hideBelow: 700,\n cell: (ctx) => renderSnippet(TrendCell, { row: ctx.row.original }),\n },\n {\n field: \"bid\",\n header: \"Bid\",\n editorType: \"number\",\n width: 90,\n editable: false,\n hideBelow: 700,\n cell: (ctx) => renderSnippet(BidCell, { row: ctx.row.original }),\n },\n {\n field: \"ask\",\n header: \"Ask\",\n editorType: \"number\",\n width: 90,\n editable: false,\n hideBelow: 700,\n cell: (ctx) => renderSnippet(AskCell, { row: ctx.row.original }),\n },\n {\n field: \"spread\",\n header: \"Spread\",\n editorType: \"number\",\n width: 95,\n editable: false,\n hideBelow: 700,\n cell: (ctx) => renderSnippet(SpreadCell, { row: ctx.row.original }),\n },\n {\n field: \"volume\",\n header: \"Volume\",\n editorType: \"number\",\n width: 115,\n editable: false,\n hideBelow: 700,\n cell: (ctx) => renderSnippet(VolumeCell, { row: ctx.row.original }),\n },\n // The only editable column. Trader sets share count; the tick stream\n // never touches it, and our onCellValueChange handler pushes the\n // change back into the parent `rows` state so subsequent ticks pick\n // up the new value when they recompute P&L.\n {\n field: \"position\",\n header: \"Position\",\n editorType: \"number\",\n width: 160,\n cell: (ctx) => renderSnippet(PositionCell, { row: ctx.row.original }),\n },\n {\n field: \"risk\",\n header: \"Risk\",\n width: 95,\n hideBelow: 700,\n editorType: \"list\",\n editorOptions: RISK_OPTIONS,\n cell: (ctx) => renderSnippet(RiskCell, { row: ctx.row.original }),\n },\n {\n field: \"pnl\",\n header: \"P&L\",\n editorType: \"number\",\n width: 130,\n editable: false,\n cell: (ctx) => renderSnippet(PnlCell, { row: ctx.row.original }),\n },\n ] satisfies ColumnDef<typeof features, Security>[]}\n {features}\n filterMode=\"menu\"\n selectionMode=\"cell\"\n enableInlineEditing={true}\n enableCellSelection={true}\n rowHeight={40}\n containerHeight=\"100%\"\n fitColumns={false}\n columnVirtualization={false}\n initialColumnPinning={{ left: [\"symbol\"], right: [\"pnl\"] }}\n responsive={true}\n {onCellValueChange}\n />\n </div>\n</section>\n\n<style>\n /* Keep every data cell at one size. Without this the grid cells inherit the\n page's 16px, which reads oddly next to the 11-13px chips/labels - the tags\n looked smaller than the numbers. 13px is the standard dense-grid size. */\n :global(.td-shell .sv-grid-cell) {\n font-size: 13px;\n }\n\n /* ─── KPI strip ──────────────────────────────────────────────── */\n .td-kpi-strip {\n display: grid;\n grid-template-columns: repeat(5, minmax(0, 1fr));\n gap: 10px;\n flex-shrink: 0;\n }\n .td-kpi {\n border: 1px solid var(--sg-border, #e2e8f0);\n background: linear-gradient(\n 180deg,\n color-mix(in srgb, var(--sg-bg, #ffffff) 100%, transparent) 0%,\n color-mix(in srgb, var(--sg-header-bg, #f8fafc) 100%, transparent) 100%\n );\n border-radius: 10px;\n padding: 12px 14px;\n box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);\n }\n .td-kpi-label {\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.06em;\n color: var(--sg-muted, #64748b);\n margin-bottom: 6px;\n }\n .td-kpi-value {\n font-size: 22px;\n font-weight: 700;\n line-height: 1.1;\n letter-spacing: -0.01em;\n }\n .td-kpi-foot {\n margin-top: 6px;\n font-size: 11px;\n color: var(--sg-muted, #64748b);\n display: flex;\n align-items: center;\n gap: 4px;\n }\n .td-pulse-dot {\n position: relative;\n display: inline-block;\n width: 7px;\n height: 7px;\n border-radius: 50%;\n background: #16a34a;\n }\n /* The ring is a scaled, fading copy of the dot: transform + opacity run on\n the compositor. Animating box-shadow repainted on the main thread every\n frame, forever, and competed with the grid for every scroll frame. */\n .td-pulse-dot::after {\n content: \"\";\n position: absolute;\n inset: 0;\n border-radius: 50%;\n background: rgba(22, 163, 74, 0.45);\n animation: td-dot-pulse 1.6s ease-out infinite;\n }\n @keyframes td-dot-pulse {\n 0% {\n transform: scale(1);\n opacity: 1;\n }\n 70%,\n 100% {\n /* 7px dot + 8px ring each side = 23px */\n transform: scale(3.3);\n opacity: 0;\n }\n }\n\n /* ─── Toolbar / sector chips ──────────────────────────────────── */\n .td-toolbar {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 6px;\n flex-shrink: 0;\n }\n .td-toolbar-spacer {\n flex: 1 1 auto;\n }\n .td-chips {\n display: contents;\n }\n .td-btn {\n border: 1px solid var(--sg-border, #cbd5e1);\n background: var(--sg-bg, #ffffff);\n color: var(--sg-fg, #1e293b);\n border-radius: 6px;\n padding: 4px 12px;\n font-size: 12px;\n cursor: pointer;\n }\n .td-btn:hover {\n background: var(--sg-row-hover-bg, #f1f5f9);\n }\n\n /* ─── Notifications bell + panel ──────────────────────────────── */\n .td-notif {\n position: relative;\n display: inline-flex;\n }\n .td-bell {\n position: relative;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 32px;\n height: 30px;\n border: 1px solid var(--sg-border, #cbd5e1);\n background: var(--sg-bg, #ffffff);\n color: var(--sg-fg, #1e293b);\n border-radius: 6px;\n cursor: pointer;\n transition:\n background 0.14s ease,\n border-color 0.14s ease;\n }\n .td-bell:hover {\n background: var(--sg-row-hover-bg, #f1f5f9);\n }\n .td-bell-on {\n color: var(--sg-accent, #2563eb);\n border-color: color-mix(in srgb, var(--sg-accent, #2563eb) 45%, var(--sg-border, #cbd5e1));\n }\n .td-bell-badge {\n position: absolute;\n top: -6px;\n right: -6px;\n min-width: 16px;\n height: 16px;\n padding: 0 4px;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n font-size: 10px;\n font-weight: 700;\n line-height: 1;\n color: #fff;\n background: #dc2626;\n border-radius: 999px;\n box-shadow: 0 0 0 2px var(--sg-bg, #fff);\n }\n .td-notif-panel {\n position: absolute;\n top: calc(100% + 8px);\n right: 0;\n z-index: 40;\n width: 300px;\n max-height: 360px;\n overflow: auto;\n background: var(--sg-bg, #ffffff);\n border: 1px solid var(--sg-border, #e2e8f0);\n border-radius: 10px;\n box-shadow: 0 12px 32px -8px rgba(15, 23, 42, 0.35);\n }\n .td-notif-head {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 10px 12px;\n font-size: 12px;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n color: var(--sg-muted, #64748b);\n border-bottom: 1px solid var(--sg-border, #e2e8f0);\n position: sticky;\n top: 0;\n background: var(--sg-bg, #ffffff);\n }\n .td-notif-clear {\n border: 0;\n background: transparent;\n color: var(--sg-accent, #2563eb);\n font-size: 11px;\n font-weight: 600;\n text-transform: none;\n letter-spacing: 0;\n cursor: pointer;\n }\n .td-notif-empty {\n padding: 28px 16px;\n text-align: center;\n font-size: 12.5px;\n color: var(--sg-muted, #64748b);\n }\n .td-notif-list {\n list-style: none;\n margin: 0;\n padding: 4px;\n display: flex;\n flex-direction: column;\n gap: 2px;\n }\n .td-notif-item {\n display: grid;\n grid-template-columns: auto 1fr;\n gap: 9px;\n align-items: start;\n padding: 9px 10px;\n border-radius: 8px;\n }\n .td-notif-item:hover {\n background: var(--sg-row-hover-bg, #f1f5f9);\n }\n .td-notif-dot {\n width: 8px;\n height: 8px;\n margin-top: 5px;\n border-radius: 50%;\n background: #16a34a;\n }\n .td-notif-item.td-down .td-notif-dot {\n background: #dc2626;\n }\n .td-notif-text {\n display: flex;\n flex-direction: column;\n min-width: 0;\n }\n .td-notif-title {\n font-size: 13px;\n font-weight: 600;\n color: var(--sg-fg, #0f172a);\n }\n .td-notif-detail {\n font-size: 11.5px;\n color: var(--sg-muted, #64748b);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n .td-hint {\n font-size: 11.5px;\n color: var(--sg-muted, #64748b);\n }\n .td-hint strong {\n color: var(--sg-fg, #1e293b);\n font-weight: 600;\n }\n\n .td-chip {\n border: 1px solid var(--sg-border, #cbd5e1);\n background: var(--sg-bg, #ffffff);\n color: var(--sg-fg, #1e293b);\n border-radius: 999px;\n padding: 3px 10px;\n font-size: 11.5px;\n font-weight: 500;\n cursor: pointer;\n line-height: 1.6;\n transition:\n transform 80ms ease-out,\n box-shadow 120ms;\n }\n .td-chip:hover {\n transform: translateY(-1px);\n box-shadow: 0 2px 4px rgba(0, 0, 0, 0.07);\n }\n .td-chip-active {\n border-color: transparent;\n box-shadow: 0 0 0 2px var(--sg-accent, #2563eb) inset;\n font-weight: 600;\n }\n .td-chip-count {\n margin-left: 6px;\n font-size: 10px;\n color: var(--sg-muted, #64748b);\n font-variant-numeric: tabular-nums;\n }\n\n /* ─── Sector tints (chips + inline pills) ─────────────────────── */\n :global(.td-sector) {\n display: inline-block;\n padding: 2px 10px;\n border-radius: 999px;\n font-size: 13px;\n font-weight: 600;\n line-height: 1.5;\n white-space: nowrap;\n }\n :global(.td-sec-technology) {\n background: #dbeafe;\n color: #1d4ed8;\n }\n :global(.td-sec-financials) {\n background: #dcfce7;\n color: #166534;\n }\n :global(.td-sec-healthcare) {\n background: #fce7f3;\n color: #9d174d;\n }\n :global(.td-sec-energy) {\n background: #fef3c7;\n color: #92400e;\n }\n :global(.td-sec-consumer) {\n background: #ede9fe;\n color: #5b21b6;\n }\n :global(.td-sec-industrials) {\n background: #e2e8f0;\n color: #334155;\n }\n :global(.td-sec-materials) {\n background: #fee2e2;\n color: #b91c1c;\n }\n :global(.td-sec-utilities) {\n background: #ccfbf1;\n color: #115e59;\n }\n :global(.td-sec-communication) {\n background: #e0e7ff;\n color: #3730a3;\n }\n :global(.td-sec-real-estate) {\n background: #ffedd5;\n color: #9a3412;\n }\n :global([data-theme=\"dark\"] .td-sec-technology) {\n background: rgba(59, 130, 246, 0.18);\n color: #93c5fd;\n }\n :global([data-theme=\"dark\"] .td-sec-financials) {\n background: rgba(34, 197, 94, 0.18);\n color: #4ade80;\n }\n :global([data-theme=\"dark\"] .td-sec-healthcare) {\n background: rgba(236, 72, 153, 0.18);\n color: #f9a8d4;\n }\n :global([data-theme=\"dark\"] .td-sec-energy) {\n background: rgba(245, 158, 11, 0.18);\n color: #fbbf24;\n }\n :global([data-theme=\"dark\"] .td-sec-consumer) {\n background: rgba(139, 92, 246, 0.18);\n color: #c4b5fd;\n }\n :global([data-theme=\"dark\"] .td-sec-industrials) {\n background: rgba(148, 163, 184, 0.18);\n color: #cbd5e1;\n }\n :global([data-theme=\"dark\"] .td-sec-materials) {\n background: rgba(239, 68, 68, 0.18);\n color: #fca5a5;\n }\n :global([data-theme=\"dark\"] .td-sec-utilities) {\n background: rgba(20, 184, 166, 0.18);\n color: #5eead4;\n }\n :global([data-theme=\"dark\"] .td-sec-communication) {\n background: rgba(99, 102, 241, 0.18);\n color: #a5b4fc;\n }\n :global([data-theme=\"dark\"] .td-sec-real-estate) {\n background: rgba(249, 115, 22, 0.18);\n color: #fdba74;\n }\n\n /* ─── Symbol cell ─────────────────────────────────────────────── */\n :global(.td-symbol) {\n display: inline-flex;\n align-items: center;\n gap: 9px;\n width: 100%;\n }\n :global(.td-logo) {\n flex-shrink: 0;\n /* A shape-following shadow so circular marks don't get a square halo. */\n filter: drop-shadow(0 1px 1.5px rgba(15, 23, 42, 0.3));\n }\n\n :global(.td-sym-meta) {\n display: inline-flex;\n flex-direction: column;\n min-width: 0;\n line-height: 1.15;\n }\n :global(.td-sym-ticker) {\n font-family: ui-monospace, SFMono-Regular, \"JetBrains Mono\", Menlo,\n monospace;\n font-weight: 700;\n letter-spacing: 0.03em;\n color: var(--sg-fg, #0f172a);\n font-size: 13px;\n }\n :global(.td-sym-exch) {\n font-size: 9.5px;\n font-weight: 600;\n letter-spacing: 0.06em;\n color: var(--sg-muted, #64748b);\n text-transform: uppercase;\n }\n\n /* ─── Pulse + change tints ────────────────────────────────────── */\n :global(.td-tick) {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n padding: 0 3px;\n border-radius: 3px;\n font-variant-numeric: tabular-nums;\n }\n :global(.td-up) {\n color: #16a34a;\n font-weight: 600;\n }\n :global(.td-down) {\n color: #dc2626;\n font-weight: 600;\n }\n :global([data-theme=\"dark\"] .td-up) {\n color: #4ade80;\n }\n :global([data-theme=\"dark\"] .td-down) {\n color: #f87171;\n }\n\n :global(.td-vol) {\n color: var(--sg-muted, #64748b);\n }\n\n :global(.td-pulse-up) {\n animation: td-pulse-up 320ms ease-out;\n }\n :global(.td-pulse-down) {\n animation: td-pulse-down 320ms ease-out;\n }\n @keyframes td-pulse-up {\n 0% {\n background: rgba(34, 197, 94, 0.55);\n }\n 100% {\n background: transparent;\n }\n }\n @keyframes td-pulse-down {\n 0% {\n background: rgba(239, 68, 68, 0.55);\n }\n 100% {\n background: transparent;\n }\n }\n\n /* ─── Sparkline ───────────────────────────────────────────────── */\n /* Direction colours live as theme-aware CSS vars on the cell, and the SVG\n reads them inline (see TrendCell). Setting the colour inline makes the\n trail reliably green/red in every context - including the home-page\n preview - instead of falling back to a neutral slate that looks blue. */\n :global(.td-trend) {\n display: flex;\n align-items: center;\n gap: 8px;\n width: 100%;\n height: 100%;\n --td-spark-up: #16a34a;\n --td-spark-down: #dc2626;\n }\n :global([data-theme=\"dark\"] .td-trend) {\n --td-spark-up: #4ade80;\n --td-spark-down: #f87171;\n }\n :global(.td-spark) {\n flex: 1 1 auto;\n min-width: 0;\n height: 68%;\n max-height: 30px;\n color: var(--sg-muted, #64748b);\n }\n :global(.td-spark-pct) {\n flex: 0 0 auto;\n width: 48px;\n text-align: right;\n font-size: 11.5px;\n font-weight: 600;\n font-variant-numeric: tabular-nums;\n }\n\n /* ─── Position bar ────────────────────────────────────────────── */\n :global(.td-pos) {\n display: flex;\n align-items: center;\n gap: 8px;\n min-width: 0;\n }\n :global(.td-pos-bar) {\n position: relative;\n flex: 1 1 auto;\n height: 6px;\n background: var(--sg-border, #e2e8f0);\n border-radius: 3px;\n overflow: hidden;\n }\n :global(.td-pos-fill) {\n position: absolute;\n inset: 0 auto 0 0;\n transition: width 500ms ease-out;\n }\n :global(.td-pos-cool) {\n background: #2563eb;\n }\n :global(.td-pos-warm) {\n background: #ca8a04;\n }\n :global(.td-pos-hot) {\n background: #dc2626;\n }\n :global([data-theme=\"dark\"] .td-pos-cool) {\n background: #60a5fa;\n }\n :global([data-theme=\"dark\"] .td-pos-warm) {\n background: #fbbf24;\n }\n :global([data-theme=\"dark\"] .td-pos-hot) {\n background: #f87171;\n }\n :global(.td-pos-text) {\n font-size: 11px;\n color: var(--sg-muted, #64748b);\n font-variant-numeric: tabular-nums;\n min-width: 28px;\n text-align: right;\n }\n\n /* ─── Risk pill ───────────────────────────────────────────────── */\n :global(.td-risk) {\n display: inline-block;\n padding: 2px 10px;\n border-radius: 4px;\n font-size: 13px;\n font-weight: 600;\n line-height: 1.5;\n }\n :global(.td-risk-low) {\n background: #dcfce7;\n color: #166534;\n }\n :global(.td-risk-med) {\n background: #fef3c7;\n color: #92400e;\n }\n :global(.td-risk-high) {\n background: #fee2e2;\n color: #b91c1c;\n }\n :global([data-theme=\"dark\"] .td-risk-low) {\n background: rgba(34, 197, 94, 0.18);\n color: #4ade80;\n }\n :global([data-theme=\"dark\"] .td-risk-med) {\n background: rgba(245, 158, 11, 0.18);\n color: #fbbf24;\n }\n :global([data-theme=\"dark\"] .td-risk-high) {\n background: rgba(239, 68, 68, 0.18);\n color: #f87171;\n }\n\n /* ─── P&L ─────────────────────────────────────────────────────── */\n :global(.td-pnl) {\n font-weight: 700;\n }\n\n /* Phones, portrait and landscape: the notification badge hangs 6px outside\n its bell button (`right: -6px`), and on a phone that button sits flush\n with the right edge of the demo stage (in portrait the spacer pushes the\n hint / Pause / bell line to the right once the chips take a line of their\n own) - so the badge was the one thing on this demo getting clipped. Tuck\n it inside the button; it still reads as a badge. */\n @media (max-width: 639px), (max-height: 500px) and (pointer: coarse),\n (min-width: 768px) and (max-width: 1023px) and (pointer: coarse) {\n .td-bell-badge {\n top: -4px;\n right: 0;\n }\n }\n\n /* Phone: the chrome above the grid has to be cheap, or the grid pays for\n it - the KPI strip and toolbar are shrink-0 and the grid pane is flex-1.\n The shared mobile layer makes the strip 2-up (three rows of these cards)\n and gives every button a 40px touch target, which stacked ~480px of KPIs\n and wrapped chips above the grid on a 384px-wide phone and squeezed the\n grid to nothing. Compact cards and a single scrolling chip row bring\n that to ~300px. */\n @media (max-width: 639px) {\n .td-kpi-strip {\n gap: 6px;\n }\n .td-kpi {\n padding: 8px 10px;\n border-radius: 8px;\n }\n .td-kpi-label {\n font-size: 10px;\n margin-bottom: 3px;\n }\n .td-kpi-value {\n font-size: 17px;\n }\n .td-kpi-foot {\n margin-top: 3px;\n font-size: 10.5px;\n }\n /* One row that pans instead of three that wrap. `flex: 1 1 100%` takes\n the toolbar's whole first line, so the hint, Pause and the bell drop to\n a second line together. */\n .td-chips {\n display: flex;\n flex: 1 1 100%;\n flex-wrap: nowrap;\n gap: 6px;\n overflow-x: auto;\n overscroll-behavior-x: contain;\n -webkit-overflow-scrolling: touch;\n scrollbar-width: none;\n }\n .td-chips::-webkit-scrollbar {\n display: none;\n }\n .td-chip {\n flex: 0 0 auto;\n }\n }\n</style>\n"
9
9
  },
10
10
  {
11
11
  "id": "01-quick-start",
@@ -4433,7 +4433,7 @@ export const docs = [
4433
4433
  "path": "docs/enterprise/studio.md",
4434
4434
  "title": "SvGrid Studio",
4435
4435
  "section": "Studio",
4436
- "markdown": "# SvGrid Studio\n\nStudio is the grid-centric data-app layer in `@svgrid/enterprise`: turn a\ndatabase or schema into a **production-ready CRUD screen** - grid, edit form,\nand SvelteKit API route - in seconds. One `EntitySchema` drives the grid\ncolumns, the edit form, validation, and the generated code.\n\n![A CRUD screen generated by SvGrid Studio: sortable, filterable grid with a native pager, over a live data source.](/docs-media/studio-crud.png)\n\nIt is part of the **Enterprise** license, and you can evaluate it\nwithout a key: loaded without one, everything runs and the watermark and the console\nmessage about the Enterprise package are displayed. See\n[licensing](./licensing.md#studio-data-app-generator).\n\n> **Just want to run it?** One command scaffolds a complete, working app - no\n> manual setup, no database required:\n>\n> ```bash\n> npm create @svgrid/studio@latest my-app\n> cd my-app && npm install && npm run dev\n> ```\n>\n> You'll be asked to pick a **theme** (one of `@svgrid/grid`'s 19 built-in\n> presets) and **light or dark** mode - or skip the prompts with\n> `-- --theme material --dark`. Open `http://localhost:5173` and you have a\n> real SvelteKit app - nav shell, **Customers** and **Orders** screens (linked\n> by a searchable lookup), grid + modal create/edit/delete, seeded data.\n> Nothing to configure. See\n> [Getting started](./studio/getting-started.md#fastest-path-a-downloadable-ready-to-run-example)\n> for what to do next.\n\n> **Want to build it yourself, step by step?** Follow the\n> **[Getting started](./studio/getting-started.md)** guide - from an empty app to\n> a working Customers screen, no prior SvGrid experience assumed.\n\n> **Prefer not to write code?** Build the same app **visually** - point, click,\n> preview, then press one button to generate it. Try it right now, no install,\n> at **[svgrid.com/studio](https://svgrid.com/studio)** - or see\n> **[Launch the designer](./studio/launch.md)** for the local CLI version\n> (auto-saves to disk, generates straight into a folder), or open a one-click\n> **[sample app](./studio/samples.md)**.\n\n> **Live demos:** [Live SQL](https://svgrid.com/demos/193-studio-live-sql/)\n> (the whole stack over real Postgres in the browser via PGlite) ·\n> [Supabase](https://svgrid.com/demos/194-studio-supabase/) (your own hosted\n> Postgres, straight from the browser) ·\n> [Chart](https://svgrid.com/demos/197-studio-chart/) (a live chart panel\n> beside the grid; click to filter).\n\n---\n\n## A CRUD app in under a minute\n\nThis assumes you already have a SvelteKit app and a `customers` table in your\ndatabase. Starting from nothing? Use **[Getting started](./studio/getting-started.md)**\ninstead - it builds the same screen from an empty folder, no database required.\n\n```bash\nnpm i @svgrid/grid @svgrid/enterprise pg # pg = driver for your database\nnpx @svgrid/studio add customers --db postgres --url \"$DATABASE_URL\"\nnpm run dev # open /customers\n```\n\n`add customers` introspects the `customers` table and writes three files - the\nentity schema, a SvelteKit `+server.ts` API route, and a `+page.svelte` screen -\ngiving you a working `/customers` page with **search, sort, pagination,\nmulti-select delete, and create/edit**. Re-running `add` is safe: only the\n`svgrid:managed` regions are regenerated.\n\n> The grid themes its own borders, backgrounds, and hover states out of the box. Font is\n> the one thing it inherits from your page rather than forcing - if your app doesn't set\n> one yet (a bare `npx sv create` scaffold doesn't), add a `body { font-family: ... }`\n> rule or a [`--sg-font`](./studio/theming.md) token, or the screen renders in the\n> browser's default serif font.\n\n---\n\n## How it fits together\n\nOne `EntitySchema` is the single source of truth. The grid columns, the edit\nform, the dashboard, the generated SvelteKit code, and the data layer all derive\nfrom it - so a change to the schema flows everywhere at once.\n\n![One EntitySchema drives the grid, edit form, dashboard, generated app, and every data source; author it with the visual designer, the CLI, or the AI generator.](/docs-media/studio-architecture.svg)\n\nNew here? [Concepts](./studio/concepts.md) walks this picture end to end and\ndefines every Studio term (screen, block, project model, managed region).\n\n---\n\n## Data binding options\n\nStudio binds a screen to data through one small contract - `ServerDataSource`\n(read + create + update + delete). Every option below implements it, so the\ngrid, form, sorting, filtering, and pagination work identically no matter where\nthe data lives. Pick your source:\n\n| Option | Use it for | Guide |\n| --- | --- | --- |\n| **Databases** | PostgreSQL, Supabase, MySQL / MariaDB, SQL Server, SQLite | [Databases](./studio/databases.md) |\n| **Drizzle schema** | scaffold from a Drizzle `schema.ts` file (no live DB needed) | [Drizzle schema](./studio/drizzle.md) |\n| **REST / custom API** | an existing HTTP API, or any backend you wire by hand | [REST & custom APIs](./studio/rest-api.md) |\n| **In-memory** | prototypes, tests, static data, seeding a schema | [In-memory](./studio/in-memory.md) |\n\nNew to how binding works? Start with **[Data binding](./studio/data-binding.md)** -\nthe `ServerDataSource` contract and how sort / filter / page / CRUD flow.\n\n---\n\n## Three ways to build\n\nAll three produce the same output, because they share one `scaffold()` core.\n\n**1. [CLI](./studio/cli.md)** - `npx @svgrid/studio add ...` (deterministic, no\nAI required).\n\n**2. [AI / MCP](./studio/ai-generation.md)** - with `@svgrid/mcp` configured, ask\nyour agent (Claude Code, Cursor, ...) to build a screen for a table; it calls\n`svgrid_scaffold`, and the output is compile-verified\nbefore it's written.\n\n**3. [Visual app designer](./studio/app-designer.md)** - run `npx @svgrid/studio\ndesigner` to open the full app builder (`SvStudioDesigner`): compose grids, charts,\ndashboards, boards, schedulers, and master-detail across **many entities and\nscreens**, bind each to real data, write code-behind, and click **Generate app**.\nThis is the rich, multi-screen builder you see when you launch Studio.\n\n![The visual app designer: a Pages and Entities rail on the left, a live grid preview with status chips in the middle, and a screen properties panel on the right.](/docs-media/studio-app-designer.png)\n\nWant to embed a schema editor in your **own** app instead? The\n[`<SvSchemaDesigner>`](./studio/designer.md) component is a smaller,\n**single-entity** widget - author one entity's fields + validation with a live grid\n+ form preview, and you own where the schema is stored.\n\nHere a ready-made **CRM sample** is open in the app designer - a forecast dashboard\nwith KPI tiles and deal-value charts, all built by pointing and clicking, no code:\n\n![A CRM sample loaded in the visual app designer: KPI tiles (open deals, pipeline value, average deal size) and a bar chart of deal value by stage.](/docs-media/studio-sample-loaded.png)\n\n---\n\n## What you get in a generated screen\n\nA shippable data screen, not a stub:\n\n- **Grid** with server-side **sort**, **filter**, **global search**, and\n columns that fit the width.\n- **Native pagination footer** driven by the server row count.\n- **Create / edit** in a themed **modal or slide-over** panel with **built-in\n validation** (required, email/URL, min/max, length, regex, plus any Zod /\n Standard Schema validator you add).\n- **Multi-select delete** with **optimistic updates** (rows change instantly,\n roll back on error).\n- **Loading, error, and empty** states, light/dark theming via the grid tokens.\n\n![The create/edit modal with built-in validation - \"Email must be a valid email\".](/docs-media/studio-edit-modal.png)\n\n---\n\n## The building blocks (use them directly)\n\n| Piece | What |\n| --- | --- |\n| `EntitySchema` | one model -> grid columns (`schemaToColumns`) + form fields (`schemaToFormFields`) + validation |\n| `SvGridEditPanel` | schema-driven create / edit form (drawer / modal / inline), with FK lookup fields |\n| `createRelationLookup` / `SvLookupInput` | searchable foreign-key picker over any `ServerDataSource` ([Relations](./studio/relations.md)) |\n| `SvGridMasterDetail` | expand a row into a nested grid of related records |\n| `createSqlDataSource` | a `ServerDataSource` over any SQL client (`execute(text, params)`) |\n| `createSupabaseDataSource` | a `ServerDataSource` over a `supabase-js` client (browser + RLS) |\n| `createSupabaseRealtime` | live grid: refetch / flash rows on Postgres change streams ([Real-time](./studio/realtime.md)) |\n| `SvAuthGate` / `createSupabaseAuth` | secure a screen behind Supabase Auth; RLS scopes data per user ([Auth](./studio/auth.md)) |\n| `SvSchemaChart` | a chart bound to the schema; aggregates client-side or via a source's `getAggregate` (`GROUP BY`) ([Dashboards](./studio/dashboards.md)) |\n| `scaffoldApp` | scaffold a whole multi-entity app - nav + home + every screen |\n| `createRestDataSource` | a `ServerDataSource` over an HTTP/JSON API (overridable request/response) |\n| `introspectSupabaseTable` | detect a Supabase table's columns -> `EntitySchema`, from the browser |\n| `createKitDataSource` / `createKitHandlers` | the SvelteKit client + server transport |\n| `createServerDataSource` | server-side sort / filter / page + optimistic CRUD |\n| `SvSchemaDesigner` | the visual schema designer + live preview + code generation |\n\nThe grid renders its **own** sort, filter, and pagination UI in server mode via\n`externalSort` / `externalFilter` / `externalPagination` - you pass `rowCount`\nand `pageIndex`, it emits change events, you fetch the page.\n\n---\n\n## Production & enterprise ready\n\n- **Real code, no lock-in** - Studio generates plain SvelteKit files you own and\n can edit. There is no runtime, no proprietary host.\n- **No telemetry, no network calls** - licensing is validated locally\n ([details](./licensing.md)); nothing phones home.\n- **Parameterized SQL** - every value is bound, and identifiers come from the\n schema-whitelisted plan, so there is no injection surface in the data layer.\n- **SvelteKit-native** - server `load` / actions, `$lib`, SSR-safe, idiomatic\n Svelte 5 runes.\n- **Bring your own key** - the AI generator uses your own model API key; your\n data and schema never touch our servers.\n- **One license** - Studio is part of the Enterprise license; no separate SKU.\n\n---\n\n## All Studio topics\n\nEvery Studio page, in reading order. The sidebar follows the same order.\n\n**Start here**\n\n- [Getting started](./studio/getting-started.md) - zero to a working CRUD screen, step by step\n- [Concepts](./studio/concepts.md) - the mental model: one `EntitySchema` drives screens, data, and code\n- [Sample apps](./studio/samples.md) - ready-made apps to open, explore, and rebind to your data\n- [Build a CRM](./studio/tutorial-crm.md) - companies, contacts, deals - a multi-entity app end to end\n\n**Three ways to build**\n\n- [The Studio CLI](./studio/cli.md) - the `add` command + all flags\n- [Launch the designer](./studio/launch.md) - `npx @svgrid/studio designer`, saved to disk as you edit\n- [Visual app designer](./studio/app-designer.md) - the full multi-screen builder (`SvStudioDesigner`)\n- [Schema designer](./studio/designer.md) - the embeddable single-entity widget (`SvSchemaDesigner`)\n- [AI generation](./studio/ai-generation.md) - the MCP server + agents\n\n**Connect to data**\n\n- [Data binding](./studio/data-binding.md) - the `ServerDataSource` contract; read this one first\n- [Databases](./studio/databases.md) - PostgreSQL, Supabase, MySQL / MariaDB, SQL Server, SQLite, Turso\n- [Local database](./studio/local-database.md) - Postgres in the browser (PGlite), no server needed\n- [Supabase](./studio/supabase.md) - hosted Postgres, from the browser or the server\n- [Drizzle schema](./studio/drizzle.md) - scaffold from a `schema.ts` file\n- [Prisma schema](./studio/prisma.md) - scaffold from a `schema.prisma` file\n- [REST & custom APIs](./studio/rest-api.md) - any HTTP / bespoke backend\n- [OData & GraphQL](./studio/odata-graphql.md) - map the grid request to either protocol\n- [In-memory](./studio/in-memory.md) - static / prototype data\n\n**One-page CRUD tutorials**\n\n- [Postgres CRUD grid](./studio/postgres-grid.md) - pick this if you have a Postgres connection string\n- [Supabase CRUD grid](./studio/supabase-grid.md) - pick this if your data lives in Supabase\n- [REST CRUD grid](./studio/rest-grid.md) - pick this if you already have an HTTP API\n- [Supabase Northwind sample](./studio/supabase-sample.md) - a relational practice dataset, ready to load\n\n**Screens & features**\n\n- [The EntitySchema](./studio/schema.md) - the model everything derives from\n- [Sorting, filtering & paging](./studio/server-grid.md) - the grid's server-mode UI\n- [Edit forms & validation](./studio/edit-forms.md) - `SvGridEditPanel`\n- [Relations](./studio/relations.md) - foreign keys, lookup pickers, related entities from the schema\n- [Master-detail](./studio/master-detail.md) - the `SvGridMasterDetail` component\n- [Dashboards & charts](./studio/dashboards.md) - KPI tiles + `SvSchemaChart` over your entities\n- [Scheduler](./studio/scheduler.md) - a calendar view of any entity with dates\n- [Dock layout](./studio/dock-layout.md) - dockable, floatable panes for workspace screens\n- [Navigation](./studio/navigation.md) - drill-through pages and row action buttons\n- [Real-time](./studio/realtime.md) - live grid over Supabase change streams\n\n**Logic & generated code**\n\n- [Business logic](./studio/business-logic.md) - computed fields, hooks, server-enforced rules\n- [Code-behind](./studio/code-behind.md) - typed event handlers that survive regeneration\n- [Code generation](./studio/code-generation.md) - what's emitted + safe regeneration\n\n**Auth & security**\n\n- [Auth & secured screens](./studio/auth.md) - Supabase Auth + RLS per user\n- [Access control](./studio/access-control.md) - roles gating screens and write actions\n- [Audit log](./studio/audit-log.md) - a server-side change trail with a viewer screen\n\n**Ship it**\n\n- [Themes & styling](./studio/theming.md) - `--sg-*` tokens, light / dark, your design system\n- [Internationalization](./studio/i18n.md) - message catalog + locale switcher\n- [Accessibility](./studio/accessibility.md) - what's handled for you and what you own\n- [Testing](./studio/testing.md) - data source, component, and end-to-end layers\n- [Deploying a Studio app](./studio/deployment.md) - production checklist\n\n**Reference & help**\n\n- [API index](./studio/api.md) - every Studio export, grouped by area\n- [Troubleshooting & FAQ](./studio/troubleshooting.md) - common issues + answers\n\n**Enterprise**\n\n- [Licensing](./licensing.md) · [MCP server](../help/mcp-server.md) · [Evaluation](./evaluation.md)\n"
4436
+ "markdown": "# SvGrid Studio\n\nStudio is the grid-centric data-app layer in `@svgrid/enterprise`: turn a\ndatabase or schema into a **production-ready CRUD screen** - grid, edit form,\nand SvelteKit API route - in seconds. One `EntitySchema` drives the grid\ncolumns, the edit form, validation, and the generated code.\n\n<!-- tutorial:mk-studio-tour -->\n<figure class=\"docs-tutorial docs-tutorial-player\" id=\"tutorial-mk-studio-tour\" data-docs-tutorial=\"mk-studio-tour\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/mk-studio-tour.mp4\" poster=\"/tutorials/mk-studio-tour.poster.webp\" width=\"960\" height=\"540\" controls playsinline preload=\"none\" aria-label=\"SvGrid Studio: from your database to a SvelteKit app, 190 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/mk-studio-tour.vtt\">Your browser does not play embedded video. <a href=\"/tutorials/mk-studio-tour.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>SvGrid Studio: from your database to a SvelteKit app</strong> (3:10, with narration). <a href=\"https://www.youtube.com/watch?v=1ryvuMVcItU\" rel=\"noopener\">Watch on YouTube</a></figcaption>\n</figure>\n<!-- /tutorial:mk-studio-tour -->\n\n![A CRUD screen generated by SvGrid Studio: sortable, filterable grid with a native pager, over a live data source.](/docs-media/studio-crud.png)\n\nIt is part of the **Enterprise** license, and you can evaluate it\nwithout a key: loaded without one, everything runs and the watermark and the console\nmessage about the Enterprise package are displayed. See\n[licensing](./licensing.md#studio-data-app-generator).\n\n> **Just want to run it?** One command scaffolds a complete, working app - no\n> manual setup, no database required:\n>\n> ```bash\n> npm create @svgrid/studio@latest my-app\n> cd my-app && npm install && npm run dev\n> ```\n>\n> You'll be asked to pick a **theme** (one of `@svgrid/grid`'s 19 built-in\n> presets) and **light or dark** mode - or skip the prompts with\n> `-- --theme material --dark`. Open `http://localhost:5173` and you have a\n> real SvelteKit app - nav shell, **Customers** and **Orders** screens (linked\n> by a searchable lookup), grid + modal create/edit/delete, seeded data.\n> Nothing to configure. See\n> [Getting started](./studio/getting-started.md#fastest-path-a-downloadable-ready-to-run-example)\n> for what to do next.\n\n> **Want to build it yourself, step by step?** Follow the\n> **[Getting started](./studio/getting-started.md)** guide - from an empty app to\n> a working Customers screen, no prior SvGrid experience assumed.\n\n> **Prefer not to write code?** Build the same app **visually** - point, click,\n> preview, then press one button to generate it. Try it right now, no install,\n> at **[svgrid.com/studio](https://svgrid.com/studio)** - or see\n> **[Launch the designer](./studio/launch.md)** for the local CLI version\n> (auto-saves to disk, generates straight into a folder), or open a one-click\n> **[sample app](./studio/samples.md)**.\n\n> **Live demos:** [Live SQL](https://svgrid.com/demos/193-studio-live-sql/)\n> (the whole stack over real Postgres in the browser via PGlite) ·\n> [Supabase](https://svgrid.com/demos/194-studio-supabase/) (your own hosted\n> Postgres, straight from the browser) ·\n> [Chart](https://svgrid.com/demos/197-studio-chart/) (a live chart panel\n> beside the grid; click to filter).\n\n---\n\n## A CRUD app in under a minute\n\nThis assumes you already have a SvelteKit app and a `customers` table in your\ndatabase. Starting from nothing? Use **[Getting started](./studio/getting-started.md)**\ninstead - it builds the same screen from an empty folder, no database required.\n\n```bash\nnpm i @svgrid/grid @svgrid/enterprise pg # pg = driver for your database\nnpx @svgrid/studio add customers --db postgres --url \"$DATABASE_URL\"\nnpm run dev # open /customers\n```\n\n`add customers` introspects the `customers` table and writes three files - the\nentity schema, a SvelteKit `+server.ts` API route, and a `+page.svelte` screen -\ngiving you a working `/customers` page with **search, sort, pagination,\nmulti-select delete, and create/edit**. Re-running `add` is safe: only the\n`svgrid:managed` regions are regenerated.\n\n> The grid themes its own borders, backgrounds, and hover states out of the box. Font is\n> the one thing it inherits from your page rather than forcing - if your app doesn't set\n> one yet (a bare `npx sv create` scaffold doesn't), add a `body { font-family: ... }`\n> rule or a [`--sg-font`](./studio/theming.md) token, or the screen renders in the\n> browser's default serif font.\n\n---\n\n## How it fits together\n\nOne `EntitySchema` is the single source of truth. The grid columns, the edit\nform, the dashboard, the generated SvelteKit code, and the data layer all derive\nfrom it - so a change to the schema flows everywhere at once.\n\n![One EntitySchema drives the grid, edit form, dashboard, generated app, and every data source; author it with the visual designer, the CLI, or the AI generator.](/docs-media/studio-architecture.svg)\n\nNew here? [Concepts](./studio/concepts.md) walks this picture end to end and\ndefines every Studio term (screen, block, project model, managed region).\n\n---\n\n## Data binding options\n\nStudio binds a screen to data through one small contract - `ServerDataSource`\n(read + create + update + delete). Every option below implements it, so the\ngrid, form, sorting, filtering, and pagination work identically no matter where\nthe data lives. Pick your source:\n\n| Option | Use it for | Guide |\n| --- | --- | --- |\n| **Databases** | PostgreSQL, Supabase, MySQL / MariaDB, SQL Server, SQLite | [Databases](./studio/databases.md) |\n| **Drizzle schema** | scaffold from a Drizzle `schema.ts` file (no live DB needed) | [Drizzle schema](./studio/drizzle.md) |\n| **REST / custom API** | an existing HTTP API, or any backend you wire by hand | [REST & custom APIs](./studio/rest-api.md) |\n| **In-memory** | prototypes, tests, static data, seeding a schema | [In-memory](./studio/in-memory.md) |\n\nNew to how binding works? Start with **[Data binding](./studio/data-binding.md)** -\nthe `ServerDataSource` contract and how sort / filter / page / CRUD flow.\n\n---\n\n## Three ways to build\n\nAll three produce the same output, because they share one `scaffold()` core.\n\n**1. [CLI](./studio/cli.md)** - `npx @svgrid/studio add ...` (deterministic, no\nAI required).\n\n**2. [AI / MCP](./studio/ai-generation.md)** - with `@svgrid/mcp` configured, ask\nyour agent (Claude Code, Cursor, ...) to build a screen for a table; it calls\n`svgrid_scaffold`, and the output is compile-verified\nbefore it's written.\n\n**3. [Visual app designer](./studio/app-designer.md)** - run `npx @svgrid/studio\ndesigner` to open the full app builder (`SvStudioDesigner`): compose grids, charts,\ndashboards, boards, schedulers, and master-detail across **many entities and\nscreens**, bind each to real data, write code-behind, and click **Generate app**.\nThis is the rich, multi-screen builder you see when you launch Studio.\n\n![The visual app designer: a Pages and Entities rail on the left, a live grid preview with status chips in the middle, and a screen properties panel on the right.](/docs-media/studio-app-designer.png)\n\nWant to embed a schema editor in your **own** app instead? The\n[`<SvSchemaDesigner>`](./studio/designer.md) component is a smaller,\n**single-entity** widget - author one entity's fields + validation with a live grid\n+ form preview, and you own where the schema is stored.\n\nHere a ready-made **CRM sample** is open in the app designer - a forecast dashboard\nwith KPI tiles and deal-value charts, all built by pointing and clicking, no code:\n\n![A CRM sample loaded in the visual app designer: KPI tiles (open deals, pipeline value, average deal size) and a bar chart of deal value by stage.](/docs-media/studio-sample-loaded.png)\n\n---\n\n## What you get in a generated screen\n\nA shippable data screen, not a stub:\n\n- **Grid** with server-side **sort**, **filter**, **global search**, and\n columns that fit the width.\n- **Native pagination footer** driven by the server row count.\n- **Create / edit** in a themed **modal or slide-over** panel with **built-in\n validation** (required, email/URL, min/max, length, regex, plus any Zod /\n Standard Schema validator you add).\n- **Multi-select delete** with **optimistic updates** (rows change instantly,\n roll back on error).\n- **Loading, error, and empty** states, light/dark theming via the grid tokens.\n\n![The create/edit modal with built-in validation - \"Email must be a valid email\".](/docs-media/studio-edit-modal.png)\n\n---\n\n## The building blocks (use them directly)\n\n| Piece | What |\n| --- | --- |\n| `EntitySchema` | one model -> grid columns (`schemaToColumns`) + form fields (`schemaToFormFields`) + validation |\n| `SvGridEditPanel` | schema-driven create / edit form (drawer / modal / inline), with FK lookup fields |\n| `createRelationLookup` / `SvLookupInput` | searchable foreign-key picker over any `ServerDataSource` ([Relations](./studio/relations.md)) |\n| `SvGridMasterDetail` | expand a row into a nested grid of related records |\n| `createSqlDataSource` | a `ServerDataSource` over any SQL client (`execute(text, params)`) |\n| `createSupabaseDataSource` | a `ServerDataSource` over a `supabase-js` client (browser + RLS) |\n| `createSupabaseRealtime` | live grid: refetch / flash rows on Postgres change streams ([Real-time](./studio/realtime.md)) |\n| `SvAuthGate` / `createSupabaseAuth` | secure a screen behind Supabase Auth; RLS scopes data per user ([Auth](./studio/auth.md)) |\n| `SvSchemaChart` | a chart bound to the schema; aggregates client-side or via a source's `getAggregate` (`GROUP BY`) ([Dashboards](./studio/dashboards.md)) |\n| `scaffoldApp` | scaffold a whole multi-entity app - nav + home + every screen |\n| `createRestDataSource` | a `ServerDataSource` over an HTTP/JSON API (overridable request/response) |\n| `introspectSupabaseTable` | detect a Supabase table's columns -> `EntitySchema`, from the browser |\n| `createKitDataSource` / `createKitHandlers` | the SvelteKit client + server transport |\n| `createServerDataSource` | server-side sort / filter / page + optimistic CRUD |\n| `SvSchemaDesigner` | the visual schema designer + live preview + code generation |\n\nThe grid renders its **own** sort, filter, and pagination UI in server mode via\n`externalSort` / `externalFilter` / `externalPagination` - you pass `rowCount`\nand `pageIndex`, it emits change events, you fetch the page.\n\n---\n\n## Production & enterprise ready\n\n- **Real code, no lock-in** - Studio generates plain SvelteKit files you own and\n can edit. There is no runtime, no proprietary host.\n- **No telemetry, no network calls** - licensing is validated locally\n ([details](./licensing.md)); nothing phones home.\n- **Parameterized SQL** - every value is bound, and identifiers come from the\n schema-whitelisted plan, so there is no injection surface in the data layer.\n- **SvelteKit-native** - server `load` / actions, `$lib`, SSR-safe, idiomatic\n Svelte 5 runes.\n- **Bring your own key** - the AI generator uses your own model API key; your\n data and schema never touch our servers.\n- **One license** - Studio is part of the Enterprise license; no separate SKU.\n\n---\n\n## All Studio topics\n\nEvery Studio page, in reading order. The sidebar follows the same order.\n\n**Start here**\n\n- [Getting started](./studio/getting-started.md) - zero to a working CRUD screen, step by step\n- [Concepts](./studio/concepts.md) - the mental model: one `EntitySchema` drives screens, data, and code\n- [Sample apps](./studio/samples.md) - ready-made apps to open, explore, and rebind to your data\n- [Build a CRM](./studio/tutorial-crm.md) - companies, contacts, deals - a multi-entity app end to end\n\n**Three ways to build**\n\n- [The Studio CLI](./studio/cli.md) - the `add` command + all flags\n- [Launch the designer](./studio/launch.md) - `npx @svgrid/studio designer`, saved to disk as you edit\n- [Visual app designer](./studio/app-designer.md) - the full multi-screen builder (`SvStudioDesigner`)\n- [Schema designer](./studio/designer.md) - the embeddable single-entity widget (`SvSchemaDesigner`)\n- [AI generation](./studio/ai-generation.md) - the MCP server + agents\n\n**Connect to data**\n\n- [Data binding](./studio/data-binding.md) - the `ServerDataSource` contract; read this one first\n- [Databases](./studio/databases.md) - PostgreSQL, Supabase, MySQL / MariaDB, SQL Server, SQLite, Turso\n- [Local database](./studio/local-database.md) - Postgres in the browser (PGlite), no server needed\n- [Supabase](./studio/supabase.md) - hosted Postgres, from the browser or the server\n- [Drizzle schema](./studio/drizzle.md) - scaffold from a `schema.ts` file\n- [Prisma schema](./studio/prisma.md) - scaffold from a `schema.prisma` file\n- [REST & custom APIs](./studio/rest-api.md) - any HTTP / bespoke backend\n- [OData & GraphQL](./studio/odata-graphql.md) - map the grid request to either protocol\n- [In-memory](./studio/in-memory.md) - static / prototype data\n\n**One-page CRUD tutorials**\n\n- [Postgres CRUD grid](./studio/postgres-grid.md) - pick this if you have a Postgres connection string\n- [Supabase CRUD grid](./studio/supabase-grid.md) - pick this if your data lives in Supabase\n- [REST CRUD grid](./studio/rest-grid.md) - pick this if you already have an HTTP API\n- [Supabase Northwind sample](./studio/supabase-sample.md) - a relational practice dataset, ready to load\n\n**Screens & features**\n\n- [The EntitySchema](./studio/schema.md) - the model everything derives from\n- [Sorting, filtering & paging](./studio/server-grid.md) - the grid's server-mode UI\n- [Edit forms & validation](./studio/edit-forms.md) - `SvGridEditPanel`\n- [Relations](./studio/relations.md) - foreign keys, lookup pickers, related entities from the schema\n- [Master-detail](./studio/master-detail.md) - the `SvGridMasterDetail` component\n- [Dashboards & charts](./studio/dashboards.md) - KPI tiles + `SvSchemaChart` over your entities\n- [Scheduler](./studio/scheduler.md) - a calendar view of any entity with dates\n- [Dock layout](./studio/dock-layout.md) - dockable, floatable panes for workspace screens\n- [Navigation](./studio/navigation.md) - drill-through pages and row action buttons\n- [Real-time](./studio/realtime.md) - live grid over Supabase change streams\n\n**Logic & generated code**\n\n- [Business logic](./studio/business-logic.md) - computed fields, hooks, server-enforced rules\n- [Code-behind](./studio/code-behind.md) - typed event handlers that survive regeneration\n- [Code generation](./studio/code-generation.md) - what's emitted + safe regeneration\n\n**Auth & security**\n\n- [Auth & secured screens](./studio/auth.md) - Supabase Auth + RLS per user\n- [Access control](./studio/access-control.md) - roles gating screens and write actions\n- [Audit log](./studio/audit-log.md) - a server-side change trail with a viewer screen\n\n**Ship it**\n\n- [Themes & styling](./studio/theming.md) - `--sg-*` tokens, light / dark, your design system\n- [Internationalization](./studio/i18n.md) - message catalog + locale switcher\n- [Accessibility](./studio/accessibility.md) - what's handled for you and what you own\n- [Testing](./studio/testing.md) - data source, component, and end-to-end layers\n- [Deploying a Studio app](./studio/deployment.md) - production checklist\n\n**Reference & help**\n\n- [API index](./studio/api.md) - every Studio export, grouped by area\n- [Troubleshooting & FAQ](./studio/troubleshooting.md) - common issues + answers\n\n**Enterprise**\n\n- [Licensing](./licensing.md) · [MCP server](../help/mcp-server.md) · [Evaluation](./evaluation.md)\n"
4437
4437
  },
4438
4438
  {
4439
4439
  "slug": "enterprise/studio/access-control",
@@ -4580,7 +4580,7 @@ export const docs = [
4580
4580
  "path": "docs/enterprise/studio/getting-started.md",
4581
4581
  "title": "Getting started",
4582
4582
  "section": "Studio",
4583
- "markdown": "# Getting started\n\nThis is the gentlest path into SvGrid Studio. By the end you will have a real,\nworking **Customers** screen - a grid you can sort, filter, and page, with a\ncreate / edit form and delete - running on your machine. No prior experience\nwith SvGrid is assumed.\n\nIf you would rather just click around first, open a live demo - no install\nneeded:\n\n- **[Studio live SQL](https://svgrid.com/demos/193-studio-live-sql/)** - the whole stack in the browser, backed by real Postgres via PGlite\n- **[Supabase](https://svgrid.com/demos/194-studio-supabase/)** - connect your own hosted Postgres\n- **[The designer itself](https://svgrid.com/studio)** - open a sample app and edit it, nothing to install\n\n**Choose your tutorial.** This page needs no database and teaches the whole\nshape; the one-page tutorials build the same screen against a real backend.\nThey all end in the same place, so pick by what you have:\n\n| You have | Follow | What it adds |\n| --- | --- | --- |\n| Nothing yet | this page | the full path: install, schema, generate, run, change |\n| A Postgres connection string | [Postgres CRUD grid](./postgres-grid.md) | a server route querying your database |\n| A Supabase project | [Supabase CRUD grid](./supabase-grid.md) | browser client, keys, Row-Level Security |\n| An HTTP / JSON API | [REST CRUD grid](./rest-grid.md) | the REST adapter, no server route at all |\n| An afternoon | [Build a CRM](./tutorial-crm.md) | multi-entity: relations, master-detail, a real DB |\n\n---\n\n<!-- tutorial:install-studio -->\n<figure class=\"docs-tutorial docs-tutorial-player\" id=\"tutorial-install-studio\" data-docs-tutorial=\"install-studio\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/install-studio.mp4\" poster=\"/tutorials/install-studio.poster.webp\" width=\"960\" height=\"540\" controls playsinline preload=\"none\" aria-label=\"SvGrid Studio: database to CRUD app, 69 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/install-studio.vtt\">Your browser does not play embedded video. <a href=\"/tutorials/install-studio.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>SvGrid Studio: database to CRUD app</strong> (1:09, with narration).</figcaption>\n</figure>\n<!-- /tutorial:install-studio -->\n\n## Guided path - answer a few questions, get the app\n\nIf you already know where your data lives, let Studio ask:\n\n```bash\nnpx @svgrid/studio init\n```\n\nIt asks four things - where the data comes from (sample data, your database,\nin-browser Postgres, or a REST API), which tables you want, which pages each\ntable gets, and what it should look like - then writes a runnable SvelteKit app\nwith a list, an edit form and a record page per table, plus an overview\ndashboard.\n\nPointing it at a real database is one line, and Studio installs the driver for\nyou:\n\n```bash\nnpx @svgrid/studio init --db postgres --url $DATABASE_URL --out my-app\n```\n\nOn Supabase there is no driver to install at all - it is read over the project's\nREST API:\n\n```bash\nnpx @svgrid/studio init --supabase-url https://xxxx.supabase.co --supabase-key $SUPABASE_ANON_KEY\n```\n\nPrefer clicking? The visual designer has the same wizard behind its **New app**\nbutton - or open [svgrid.com/studio/new](https://svgrid.com/studio/new) to start\none in the browser. Both paths run the same generator, so they produce the same\napp. See [The Studio CLI](./cli.md#init) for every flag.\n\n---\n\n## Fastest path - a downloadable, ready-to-run example\n\nRather have a working project on your machine than type code into a blank\nfile? One command scaffolds a complete SvelteKit app with everything already\nwired up:\n\n```bash\nnpm create @svgrid/studio@latest my-app\n```\n\nYou'll be asked to pick a **theme** - one of `@svgrid/grid`'s 19 built-in\npresets (shadcn, Tailwind, Material, Excel, Fluent, and more) - and whether to\nstart in **light or dark** mode. Scripting this instead? Both are flags:\n\n```bash\nnpm create @svgrid/studio@latest my-app -- --theme material --dark\n```\n\nThen:\n\n```bash\ncd my-app\nnpm install\nnpm run dev\n```\n\nOpen `http://localhost:5173`. You get:\n\n- A **nav shell** and a home page (`src/routes/+layout.svelte`).\n- Two linked entities - **Customers** and **Orders** - each a full grid +\n modal create/edit/delete screen. Orders has a searchable lookup back to\n Customers, so you can see how relations work.\n- **Seeded in-memory data** - nothing to install or configure, no database.\n- The **theme and mode you picked**, applied to the whole app - not just the grid.\n\nIt is a real project, not a read-only demo - edit it, add fields, connect a\ndatabase, deploy it. A few places to start:\n\n| Want to... | Edit |\n| --- | --- |\n| Add or change a field | `src/lib/schemas.ts` - the grid and the form update together |\n| See how a screen is built | `src/lib/EntityScreen.svelte` - the reusable grid + modal CRUD screen every route uses |\n| Connect a real database | `src/lib/data.ts` - swap `createInMemoryDataSource` for `createSqlDataSource` / `createSupabaseDataSource` (see [Databases](./databases.md)) |\n| Add another screen from a live table | `npx @svgrid/studio add invoices --db postgres --url \"$DATABASE_URL\"` |\n\nThe template's own `README.md` covers the same ground once you're in the\nproject. Prefer to see each piece built up by hand instead, or add a screen to\nan *existing* app rather than a fresh one? Continue below.\n\n---\n\n## Three ways to build - pick yours\n\n![Build a data app in four no-code steps: open the designer, start from a sample or your database, arrange it visually, then generate the app.](/docs-media/studio-nocode-steps.svg)\n\nThis is the visual designer - a screens list on the left, your data previewed\nlive in the middle, and simple property panels on the right. You point, click, and\npress **Generate**:\n\n![The visual app designer with a customer grid previewed live and a properties panel for the screen and its fields.](/docs-media/studio-app-designer.png)\n\n- **Visual designer (no code).** You never write code - you point, click, and\n preview, then press one button to generate the finished app. Try it\n immediately, no install, at **[svgrid.com/studio](https://svgrid.com/studio)** -\n or run it locally with **[Launch the designer](./launch.md)**, which\n auto-saves to disk and generates straight into a folder. The\n [sample apps](./samples.md) let you open a complete, realistic app in one\n click and point it at your own data.\n- **CLI.** One deterministic command per screen: `npx @svgrid/studio add ...`\n introspects your table or schema and writes the files. No AI involved. This\n page uses the CLI from Step 3 on - continue below.\n- **AI via MCP.** With [`@svgrid/mcp`](./ai-generation.md) configured, ask your\n coding agent to build the screen; it introspects, scaffolds, and\n compile-verifies through the same core the CLI uses.\n\nAll three produce the **same generated code** - pick whichever fits how you\nwork, and switch freely later.\n\n---\n\n## What you need\n\n- **[Node.js](https://nodejs.org) 18 or newer.** Check with `node -v` in a\n terminal. If that errors, install Node first.\n- **A terminal** and a code editor (VS Code is fine).\n- **A SvelteKit app.** Don't have one? Create one in 30 seconds:\n\n ```bash\n npx sv create my-app # pick \"SvelteKit minimal\", TypeScript: yes\n cd my-app\n npm install\n ```\n\nYou do **not** need a database to start - the first screen below runs on\nin-memory data. You can point it at PostgreSQL, Supabase, MySQL, and others\nlater without changing the UI.\n\n---\n\n## Step 1 - Install\n\nInside your app folder:\n\n```bash\nnpm i @svgrid/grid @svgrid/enterprise\n```\n\n- `@svgrid/grid` is the grid itself.\n- `@svgrid/enterprise` adds Studio: the schema, the edit form, and the data-source\n helpers. No key is needed to evaluate it; loaded without a key\n it displays a watermark and a console message about the Enterprise\n package, and everything still runs. See [licensing](../licensing.md).\n\n---\n\n## Step 2 - Describe your data once\n\nThe generator needs one description of your table. If you have a live database\nit can introspect it directly (Step 3 shows that variant). Here we stay\ndatabase-free: describe the table in a small Drizzle schema file, which the\ngenerator **reads as text** - it never connects to anything.\n\n```bash\nnpm i -D drizzle-orm\n```\n\n(`drizzle-orm` is only there so the schema file type-checks; nothing runs\nagainst a database. It is also the natural next step when you do add one.)\n\nCreate `src/lib/db/schema.ts`:\n\n```ts\nimport { pgTable, text, integer, boolean } from 'drizzle-orm/pg-core'\n\nexport const customers = pgTable('customers', {\n id: text('id').primaryKey(),\n name: text('name').notNull(),\n email: text('email').notNull(),\n tier: text('tier').notNull().default('free'),\n mrr: integer('mrr'),\n active: boolean('active'),\n})\n```\n\nA Prisma `schema.prisma` works the same way. Prefer to write Studio's own\nmodel - the `EntitySchema` - by hand instead? That is the\n[appendix](#appendix-wire-it-by-hand-no-generator) at the bottom of this page.\n\n---\n\n## Step 3 - Generate the screen\n\nOne command:\n\n```bash\nnpx @svgrid/studio add customers --from src/lib/db/schema.ts\n```\n\nHave a live database instead? Same command, different source - no schema file\nneeded:\n\n```bash\nnpx @svgrid/studio add customers --db postgres --url \"$DATABASE_URL\"\n```\n\nEither way it writes **three files**, and the screen is done. A quick tour of\nwhat you now own:\n\n**1. `src/lib/customers.schema.ts` - the model.** The generator turned your\ntable into an `EntitySchema` - the single object that drives the grid columns,\nthe form fields, and validation:\n\n```ts\nexport type CustomersRow = {\n id: string\n name: string\n email: string\n tier: string\n mrr: number | null\n active: boolean | null\n}\n\nexport const customersSchema: EntitySchema<CustomersRow> = {\n name: 'customers',\n idField: 'id',\n fields: [\n { field: 'id', type: 'text', primaryKey: true, readonly: true },\n { field: 'name', type: 'text', required: true },\n { field: 'email', type: 'text', required: true },\n // ...\n ],\n}\n```\n\nEvery field option (labels, enum choices, min/max, regex, custom validators) is\nexplained in [The EntitySchema](./schema.md).\n\n**2. `src/routes/api/customers/+server.ts` - the API route.** A\n`ServerDataSource` (read + create + update + delete) exposed over one SvelteKit\nendpoint. With `--from` it starts in-memory so it runs immediately; with `--db`\nit is already wired to your database:\n\n```ts\nconst source = createInMemoryDataSource<CustomersRow>([], customersSchema)\n\nexport const { POST } = createKitHandlers({ schema: customersSchema, source })\n```\n\nSwapping in a real database later means replacing that one `source` line - the\npage never changes. See [Databases](./databases.md).\n\n**3. `src/routes/customers/+page.svelte` - the screen.** The grid with\nserver-side sort, filter, global search, a native pagination footer,\nmulti-select delete with optimistic updates, and a modal create / edit form -\nall reading through the API route:\n\n```ts\nconst source = createKitDataSource<CustomersRow>({ endpoint: '/api/customers' })\nconst columns = schemaToColumns(customersSchema)\nconst controller = createServerDataSource<CustomersRow>(source, {\n pageSize: 25, optimistic: true,\n getRowId: (r) => String(r.id),\n onChange: (s) => (state = s),\n})\n```\n\nEach file wraps its generated body in `// svgrid:managed:start` /\n`// svgrid:managed:end` markers. Everything you write **outside** the markers\nis yours; re-running `add` only rewrites what is inside. That is what makes\nStep 5 safe.\n\n> One thing the generated screen inherits from your page: the **font**. A bare\n> `npx sv create` app sets no CSS at all, so add a\n> `body { font-family: system-ui, sans-serif }` rule (or a\n> [`--sg-font`](./theming.md) token) once, or the page renders in the\n> browser's default serif. Borders, backgrounds, and hover states the grid\n> themes itself.\n\n---\n\n## Step 4 - Run it\n\n```bash\nnpm run dev\n```\n\nOpen the URL it prints (usually `http://localhost:5173`) and go to\n**`/customers`**. With `--from` the grid starts empty (in-memory source, no\nseed) - click **New** and add two or three customers, then try the screen:\n\n- Click a **column header** to sort.\n- Type in the **filter row** under a header to filter (it stays focused as you type).\n- Click a **row** to edit. The form validates as you type - clear a required\n field and watch it complain.\n- Select rows with the checkboxes and **Delete** them - the grid updates\n instantly and rolls back if the server says no.\n- Page through with the **native pager** at the bottom.\n\n![A CRUD screen generated by SvGrid Studio: sortable, filterable grid with a native pager, over a live data source.](/docs-media/studio-crud.png)\n\n---\n\n## Step 5 - Change something\n\nBecause the schema drives everything, changes are one edit. Add a column to\n`src/lib/db/schema.ts`:\n\n```ts\ncountry: text('country'),\n```\n\nThen re-run the exact same command:\n\n```bash\nnpx @svgrid/studio add customers --from src/lib/db/schema.ts\n```\n\nThe managed regions are regenerated: the grid gets a **Country** column and the\nedit form gets a **Country** input. Anything you wrote outside the\n`svgrid:managed` markers - extra buttons, styles, handlers - is untouched.\nThat round trip (change schema, re-run, keep your code) is the everyday\nworkflow; [Code generation](./code-generation.md) explains the rules.\n\nPrefer not to re-run the generator? Editing the generated\n`customers.schema.ts` directly works too - grid and form update together from\nthe one schema object.\n\n---\n\n## Where to go next\n\nYou have the whole shape now. The usual next steps:\n\n- **Understand the model** - [Concepts](./concepts.md) walks the pipeline\n (schema, screens, data source, codegen) once and defines every Studio term.\n- **Connect a real database** - re-run `add` with `--db` and a connection\n string, or swap the one `source` line in the API route; the page does not\n change. For Supabase, follow the one-page\n **[Supabase CRUD grid tutorial](./supabase-grid.md)**; for SQL, see\n [Databases](./databases.md).\n- **Design visually** - `npx @svgrid/studio designer` opens the full app builder:\n compose screens across entities, bind data, and click *Generate app*. See the\n [Visual app designer](./app-designer.md). (To embed a single-entity schema editor\n in your own app, see the [Schema designer](./designer.md).)\n- **Build a full app** - the [Build a CRM tutorial](./tutorial-crm.md) wires up\n companies, contacts, and deals with relations and master-detail.\n\n---\n\n## Appendix: wire it by hand (no generator)\n\nEverything the generator wrote in Step 3 can be built up by hand - useful when\nyou want to see exactly how the pieces fit, or to embed a Studio screen in an\nunusual spot. Two files replace the three generated ones (no API route: here\nthe data source lives in the page itself).\n\nFirst, the `EntitySchema` - Studio's own model, the object the generator\nderived from your Drizzle file. Create `src/lib/customers.ts`:\n\n```ts\nimport type { EntitySchema } from '@svgrid/enterprise'\n\nexport type Customer = {\n id: string\n name: string\n email: string\n tier: 'free' | 'pro' | 'enterprise'\n mrr: number\n active: boolean\n}\n\nexport const customersSchema: EntitySchema<Customer> = {\n name: 'customers',\n label: 'Customer',\n idField: 'id',\n fields: [\n { field: 'id', type: 'text', primaryKey: true, readonly: true },\n { field: 'name', type: 'text', required: true, minLength: 2 },\n { field: 'email', type: 'text', label: 'Email', required: true, format: 'email' },\n { field: 'tier', type: 'enum', options: [\n { value: 'free', label: 'Free' },\n { value: 'pro', label: 'Pro' },\n { value: 'enterprise', label: 'Enterprise' },\n ] },\n { field: 'mrr', type: 'number', label: 'MRR ($)', min: 0 },\n { field: 'active', type: 'boolean' },\n ],\n}\n```\n\nThen the page. `createInMemoryDataSource` provides the `ServerDataSource`\ncontract over a plain array, `createServerDataSource` runs sort / filter /\npage / CRUD against it, and the grid + edit panel render it. Create\n`src/routes/customers/+page.svelte`:\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid, createServerDataSource, type ServerState } from '@svgrid/grid'\n import { SvGridEditPanel, createInMemoryDataSource, schemaToColumns } from '@svgrid/enterprise'\n import { customersSchema, type Customer } from '$lib/customers'\n\n const seed: Customer[] = [\n { id: 'c1', name: 'Ada Lovelace', email: 'ada@analytic.io', tier: 'enterprise', mrr: 1200, active: true },\n { id: 'c2', name: 'Alan Turing', email: 'alan@bletchley.uk', tier: 'pro', mrr: 240, active: true },\n { id: 'c3', name: 'Grace Hopper', email: 'grace@navy.mil', tier: 'enterprise', mrr: 980, active: true },\n ]\n\n const columns = schemaToColumns(customersSchema)\n const source = createInMemoryDataSource(seed, customersSchema)\n\n let view = $state<ServerState<Customer>>({\n rows: [], total: 0, loading: false, saving: false, error: null,\n pageIndex: 0, pageSize: 10, pageCount: 1, sortModel: [], filterModel: {},\n })\n let editing = $state<Customer | null | undefined>(undefined)\n let genId = 4\n\n const controller = createServerDataSource(source, {\n pageSize: 10, optimistic: true, getRowId: (r) => r.id,\n onChange: (s) => (view = s),\n })\n controller.refresh()\n\n async function save({ mode, id, values }) {\n if (mode === 'create') { await controller.createRow({ id: `c${genId++}`, ...values }); controller.setPage(view.pageCount - 1) }\n else if (id) { await controller.updateRow(id, values) }\n editing = undefined\n }\n</script>\n\n<style>\n :global(body) {\n font-family: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n }\n</style>\n\n<button onclick={() => (editing = null)}>+ New customer</button>\n\n<SvGrid\n data={view.rows} {columns} loading={view.loading}\n fitColumns enableRowSummaries={false}\n sortable externalSort onSortingChange={(s) => controller.setSort(s)}\n filterable filterMode=\"row\" externalFilter\n onFiltersChange={(f) => controller.setFilter({\n global: f.global || undefined,\n columns: Object.fromEntries(f.columns.map((c) => [c.id, { operator: c.operator, value: c.value, valueTo: c.valueTo, selectedValues: c.selectedValues }])),\n })}\n onRowClick={(e) => (editing = e.row)}\n showPagination externalPagination\n rowCount={view.total} pageIndex={view.pageIndex} pageSize={view.pageSize}\n onPaginationChange={({ pageIndex, pageSize }) => pageSize !== view.pageSize ? controller.setPageSize(pageSize) : controller.setPage(pageIndex)}\n/>\n\n{#if editing !== undefined}\n <SvGridEditPanel schema={customersSchema} row={editing} presentation=\"modal\"\n onSubmit={save} onCancel={() => (editing = undefined)} />\n{/if}\n```\n\nThe `<style>` block is just a plain font reset - a fresh `npx sv create` app ships no CSS\nat all, so without it the page falls back to the browser's default serif font. `<SvGrid>`\nand `<SvGridEditPanel>` already theme their own borders, backgrounds, and hover states out\nof the box (via [`--sg-*` tokens](../../help/tokens.md) with built-in fallbacks) - font is\nthe one thing they intentionally inherit from the page rather than force, so it fits\nwhatever type your app already uses. If your app already sets a body font (or a\n[`--sg-font`](./theming.md) token), skip this block.\n\n---\n\n## See also\n\n- [SvGrid Studio overview](../studio.md)\n- [Concepts](./concepts.md) - the mental model + glossary\n- [Data binding](./data-binding.md) - the `ServerDataSource` contract in depth\n- [Edit forms & validation](./edit-forms.md)\n- [Troubleshooting & FAQ](./troubleshooting.md)\n"
4583
+ "markdown": "# Getting started\n\nThis is the gentlest path into SvGrid Studio. By the end you will have a real,\nworking **Customers** screen - a grid you can sort, filter, and page, with a\ncreate / edit form and delete - running on your machine. No prior experience\nwith SvGrid is assumed.\n\nIf you would rather just click around first, open a live demo - no install\nneeded:\n\n- **[Studio live SQL](https://svgrid.com/demos/193-studio-live-sql/)** - the whole stack in the browser, backed by real Postgres via PGlite\n- **[Supabase](https://svgrid.com/demos/194-studio-supabase/)** - connect your own hosted Postgres\n- **[The designer itself](https://svgrid.com/studio)** - open a sample app and edit it, nothing to install\n\n**Choose your tutorial.** This page needs no database and teaches the whole\nshape; the one-page tutorials build the same screen against a real backend.\nThey all end in the same place, so pick by what you have:\n\n| You have | Follow | What it adds |\n| --- | --- | --- |\n| Nothing yet | this page | the full path: install, schema, generate, run, change |\n| A Postgres connection string | [Postgres CRUD grid](./postgres-grid.md) | a server route querying your database |\n| A Supabase project | [Supabase CRUD grid](./supabase-grid.md) | browser client, keys, Row-Level Security |\n| An HTTP / JSON API | [REST CRUD grid](./rest-grid.md) | the REST adapter, no server route at all |\n| An afternoon | [Build a CRM](./tutorial-crm.md) | multi-entity: relations, master-detail, a real DB |\n\n---\n\n<!-- tutorial:install-studio -->\n<figure class=\"docs-tutorial docs-tutorial-player\" id=\"tutorial-install-studio\" data-docs-tutorial=\"install-studio\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/install-studio.mp4\" poster=\"/tutorials/install-studio.poster.webp\" width=\"960\" height=\"540\" controls playsinline preload=\"none\" aria-label=\"SvGrid Studio: database to CRUD app, 69 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/install-studio.vtt\">Your browser does not play embedded video. <a href=\"/tutorials/install-studio.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>SvGrid Studio: database to CRUD app</strong> (1:09, with narration).</figcaption>\n</figure>\n<!-- /tutorial:install-studio -->\n\n## Guided path - answer a few questions, get the app\n\nIf you already know where your data lives, let Studio ask:\n\n```bash\nnpx @svgrid/studio init\n```\n\nIt asks four things - where the data comes from (sample data, your database,\nin-browser Postgres, or a REST API), which tables you want, which pages each\ntable gets, and what it should look like - then writes a runnable SvelteKit app\nwith a list, an edit form and a record page per table, plus an overview\ndashboard.\n\nPointing it at a real database is one line, and Studio installs the driver for\nyou:\n\n```bash\nnpx @svgrid/studio init --db postgres --url $DATABASE_URL --out my-app\n```\n\nOn Supabase there is no driver to install at all - it is read over the project's\nREST API:\n\n```bash\nnpx @svgrid/studio init --supabase-url https://xxxx.supabase.co --supabase-key $SUPABASE_ANON_KEY\n```\n\nPrefer clicking? The visual designer has the same wizard behind its **New app**\nbutton - or open [svgrid.com/studio/new](https://svgrid.com/studio/new) to start\none in the browser. Both paths run the same generator, so they produce the same\napp. See [The Studio CLI](./cli.md#init) for every flag.\n\n---\n\n## Fastest path - a downloadable, ready-to-run example\n\nRather have a working project on your machine than type code into a blank\nfile? One command scaffolds a complete SvelteKit app with everything already\nwired up:\n\n```bash\nnpm create @svgrid/studio@latest my-app\n```\n\nYou'll be asked to pick a **theme** - one of `@svgrid/grid`'s 19 built-in\npresets (shadcn, Tailwind, Material, Excel, Fluent, and more) - and whether to\nstart in **light or dark** mode. Scripting this instead? Both are flags:\n\n```bash\nnpm create @svgrid/studio@latest my-app -- --theme material --dark\n```\n\nThen:\n\n```bash\ncd my-app\nnpm install\nnpm run dev\n```\n\nOpen `http://localhost:5173`. You get:\n\n- A **nav shell** and a home page (`src/routes/+layout.svelte`).\n- Three linked entities - **Customers**, **Orders** and **Products** - each a\n full grid + modal create/edit/delete screen. Orders has a searchable lookup\n back to Customers, so you can see how relations work.\n- **Seeded in-memory data** - nothing to install or configure, no database.\n- The **theme and mode you picked**, applied to the whole app - not just the grid.\n\nIt is a real project, not a read-only demo - edit it, add fields, connect a\ndatabase, deploy it. A few places to start:\n\n| Want to... | Edit |\n| --- | --- |\n| Add or change a field | `src/lib/schemas.ts` - the grid and the form update together |\n| See how a screen is built | `src/lib/EntityScreen.svelte` - the reusable grid + modal CRUD screen every route uses |\n| Connect a real database | `src/lib/data.ts` - swap `createInMemoryDataSource` for `createSqlDataSource` / `createSupabaseDataSource` (see [Databases](./databases.md)) |\n| Add another screen from a live table | `npx @svgrid/studio add invoices --db postgres --url \"$DATABASE_URL\"` |\n\nThe template's own `README.md` covers the same ground once you're in the\nproject. Prefer to see each piece built up by hand instead, or add a screen to\nan *existing* app rather than a fresh one? Continue below.\n\n---\n\n## Three ways to build - pick yours\n\n![Build a data app in four no-code steps: open the designer, start from a sample or your database, arrange it visually, then generate the app.](/docs-media/studio-nocode-steps.svg)\n\nThis is the visual designer - a screens list on the left, your data previewed\nlive in the middle, and simple property panels on the right. You point, click, and\npress **Generate**:\n\n![The visual app designer with a customer grid previewed live and a properties panel for the screen and its fields.](/docs-media/studio-app-designer.png)\n\n- **Visual designer (no code).** You never write code - you point, click, and\n preview, then press one button to generate the finished app. Try it\n immediately, no install, at **[svgrid.com/studio](https://svgrid.com/studio)** -\n or run it locally with **[Launch the designer](./launch.md)**, which\n auto-saves to disk and generates straight into a folder. The\n [sample apps](./samples.md) let you open a complete, realistic app in one\n click and point it at your own data.\n- **CLI.** One deterministic command per screen: `npx @svgrid/studio add ...`\n introspects your table or schema and writes the files. No AI involved. This\n page uses the CLI from Step 3 on - continue below.\n- **AI via MCP.** With [`@svgrid/mcp`](./ai-generation.md) configured, ask your\n coding agent to build the screen; it introspects, scaffolds, and\n compile-verifies through the same core the CLI uses.\n\nAll three produce the **same generated code** - pick whichever fits how you\nwork, and switch freely later.\n\n---\n\n## What you need\n\n- **[Node.js](https://nodejs.org) 18 or newer.** Check with `node -v` in a\n terminal. If that errors, install Node first.\n- **A terminal** and a code editor (VS Code is fine).\n- **A SvelteKit app.** Don't have one? Create one in 30 seconds:\n\n ```bash\n npx sv create my-app # pick \"SvelteKit minimal\", TypeScript: yes\n cd my-app\n npm install\n ```\n\nYou do **not** need a database to start - the first screen below runs on\nin-memory data. You can point it at PostgreSQL, Supabase, MySQL, and others\nlater without changing the UI.\n\n---\n\n## Step 1 - Install\n\nInside your app folder:\n\n```bash\nnpm i @svgrid/grid @svgrid/enterprise\n```\n\n- `@svgrid/grid` is the grid itself.\n- `@svgrid/enterprise` adds Studio: the schema, the edit form, and the data-source\n helpers. No key is needed to evaluate it; loaded without a key\n it displays a watermark and a console message about the Enterprise\n package, and everything still runs. See [licensing](../licensing.md).\n\n---\n\n## Step 2 - Describe your data once\n\nThe generator needs one description of your table. If you have a live database\nit can introspect it directly (Step 3 shows that variant). Here we stay\ndatabase-free: describe the table in a small Drizzle schema file, which the\ngenerator **reads as text** - it never connects to anything.\n\n```bash\nnpm i -D drizzle-orm\n```\n\n(`drizzle-orm` is only there so the schema file type-checks; nothing runs\nagainst a database. It is also the natural next step when you do add one.)\n\nCreate `src/lib/db/schema.ts`:\n\n```ts\nimport { pgTable, text, integer, boolean } from 'drizzle-orm/pg-core'\n\nexport const customers = pgTable('customers', {\n id: text('id').primaryKey(),\n name: text('name').notNull(),\n email: text('email').notNull(),\n tier: text('tier').notNull().default('free'),\n mrr: integer('mrr'),\n active: boolean('active'),\n})\n```\n\nA Prisma `schema.prisma` works the same way. Prefer to write Studio's own\nmodel - the `EntitySchema` - by hand instead? That is the\n[appendix](#appendix-wire-it-by-hand-no-generator) at the bottom of this page.\n\n---\n\n## Step 3 - Generate the screen\n\nOne command:\n\n```bash\nnpx @svgrid/studio add customers --from src/lib/db/schema.ts\n```\n\nHave a live database instead? Same command, different source - no schema file\nneeded:\n\n```bash\nnpx @svgrid/studio add customers --db postgres --url \"$DATABASE_URL\"\n```\n\nEither way it writes **three files**, and the screen is done. A quick tour of\nwhat you now own:\n\n**1. `src/lib/customers.schema.ts` - the model.** The generator turned your\ntable into an `EntitySchema` - the single object that drives the grid columns,\nthe form fields, and validation:\n\n```ts\nexport type CustomersRow = {\n id: string\n name: string\n email: string\n tier: string\n mrr: number | null\n active: boolean | null\n}\n\nexport const customersSchema: EntitySchema<CustomersRow> = {\n name: 'customers',\n idField: 'id',\n fields: [\n { field: 'id', type: 'text', primaryKey: true, readonly: true },\n { field: 'name', type: 'text', required: true },\n { field: 'email', type: 'text', required: true },\n // ...\n ],\n}\n```\n\nEvery field option (labels, enum choices, min/max, regex, custom validators) is\nexplained in [The EntitySchema](./schema.md).\n\n**2. `src/routes/api/customers/+server.ts` - the API route.** A\n`ServerDataSource` (read + create + update + delete) exposed over one SvelteKit\nendpoint. With `--from` it starts in-memory so it runs immediately; with `--db`\nit is already wired to your database:\n\n```ts\nconst source = createInMemoryDataSource<CustomersRow>([], customersSchema)\n\nexport const { POST } = createKitHandlers({ schema: customersSchema, source })\n```\n\nSwapping in a real database later means replacing that one `source` line - the\npage never changes. See [Databases](./databases.md).\n\n**3. `src/routes/customers/+page.svelte` - the screen.** The grid with\nserver-side sort, filter, global search, a native pagination footer,\nmulti-select delete with optimistic updates, and a modal create / edit form -\nall reading through the API route:\n\n```ts\nconst source = createKitDataSource<CustomersRow>({ endpoint: '/api/customers' })\nconst columns = schemaToColumns(customersSchema)\nconst controller = createServerDataSource<CustomersRow>(source, {\n pageSize: 25, optimistic: true,\n getRowId: (r) => String(r.id),\n onChange: (s) => (state = s),\n})\n```\n\nEach file wraps its generated body in `// svgrid:managed:start` /\n`// svgrid:managed:end` markers. Everything you write **outside** the markers\nis yours; re-running `add` only rewrites what is inside. That is what makes\nStep 5 safe.\n\n> One thing the generated screen inherits from your page: the **font**. A bare\n> `npx sv create` app sets no CSS at all, so add a\n> `body { font-family: system-ui, sans-serif }` rule (or a\n> [`--sg-font`](./theming.md) token) once, or the page renders in the\n> browser's default serif. Borders, backgrounds, and hover states the grid\n> themes itself.\n\n---\n\n## Step 4 - Run it\n\n```bash\nnpm run dev\n```\n\nOpen the URL it prints (usually `http://localhost:5173`) and go to\n**`/customers`**. With `--from` the grid starts empty (in-memory source, no\nseed) - click **New** and add two or three customers, then try the screen:\n\n- Click a **column header** to sort.\n- Type in the **filter row** under a header to filter (it stays focused as you type).\n- Click a **row** to edit. The form validates as you type - clear a required\n field and watch it complain.\n- Select rows with the checkboxes and **Delete** them - the grid updates\n instantly and rolls back if the server says no.\n- Page through with the **native pager** at the bottom.\n\n![A CRUD screen generated by SvGrid Studio: sortable, filterable grid with a native pager, over a live data source.](/docs-media/studio-crud.png)\n\n---\n\n## Step 5 - Change something\n\nBecause the schema drives everything, changes are one edit. Add a column to\n`src/lib/db/schema.ts`:\n\n```ts\ncountry: text('country'),\n```\n\nThen re-run the exact same command:\n\n```bash\nnpx @svgrid/studio add customers --from src/lib/db/schema.ts\n```\n\nThe managed regions are regenerated: the grid gets a **Country** column and the\nedit form gets a **Country** input. Anything you wrote outside the\n`svgrid:managed` markers - extra buttons, styles, handlers - is untouched.\nThat round trip (change schema, re-run, keep your code) is the everyday\nworkflow; [Code generation](./code-generation.md) explains the rules.\n\nPrefer not to re-run the generator? Editing the generated\n`customers.schema.ts` directly works too - grid and form update together from\nthe one schema object.\n\n---\n\n## Where to go next\n\nYou have the whole shape now. The usual next steps:\n\n- **Understand the model** - [Concepts](./concepts.md) walks the pipeline\n (schema, screens, data source, codegen) once and defines every Studio term.\n- **Connect a real database** - re-run `add` with `--db` and a connection\n string, or swap the one `source` line in the API route; the page does not\n change. For Supabase, follow the one-page\n **[Supabase CRUD grid tutorial](./supabase-grid.md)**; for SQL, see\n [Databases](./databases.md).\n- **Design visually** - `npx @svgrid/studio designer` opens the full app builder:\n compose screens across entities, bind data, and click *Generate app*. See the\n [Visual app designer](./app-designer.md). (To embed a single-entity schema editor\n in your own app, see the [Schema designer](./designer.md).)\n- **Build a full app** - the [Build a CRM tutorial](./tutorial-crm.md) wires up\n companies, contacts, and deals with relations and master-detail.\n\n---\n\n## Appendix: wire it by hand (no generator)\n\nEverything the generator wrote in Step 3 can be built up by hand - useful when\nyou want to see exactly how the pieces fit, or to embed a Studio screen in an\nunusual spot. Two files replace the three generated ones (no API route: here\nthe data source lives in the page itself).\n\nFirst, the `EntitySchema` - Studio's own model, the object the generator\nderived from your Drizzle file. Create `src/lib/customers.ts`:\n\n```ts\nimport type { EntitySchema } from '@svgrid/enterprise'\n\nexport type Customer = {\n id: string\n name: string\n email: string\n tier: 'free' | 'pro' | 'enterprise'\n mrr: number\n active: boolean\n}\n\nexport const customersSchema: EntitySchema<Customer> = {\n name: 'customers',\n label: 'Customer',\n idField: 'id',\n fields: [\n { field: 'id', type: 'text', primaryKey: true, readonly: true },\n { field: 'name', type: 'text', required: true, minLength: 2 },\n { field: 'email', type: 'text', label: 'Email', required: true, format: 'email' },\n { field: 'tier', type: 'enum', options: [\n { value: 'free', label: 'Free' },\n { value: 'pro', label: 'Pro' },\n { value: 'enterprise', label: 'Enterprise' },\n ] },\n { field: 'mrr', type: 'number', label: 'MRR ($)', min: 0 },\n { field: 'active', type: 'boolean' },\n ],\n}\n```\n\nThen the page. `createInMemoryDataSource` provides the `ServerDataSource`\ncontract over a plain array, `createServerDataSource` runs sort / filter /\npage / CRUD against it, and the grid + edit panel render it. Create\n`src/routes/customers/+page.svelte`:\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid, createServerDataSource, type ServerState } from '@svgrid/grid'\n import { SvGridEditPanel, createInMemoryDataSource, schemaToColumns } from '@svgrid/enterprise'\n import { customersSchema, type Customer } from '$lib/customers'\n\n const seed: Customer[] = [\n { id: 'c1', name: 'Ada Lovelace', email: 'ada@analytic.io', tier: 'enterprise', mrr: 1200, active: true },\n { id: 'c2', name: 'Alan Turing', email: 'alan@bletchley.uk', tier: 'pro', mrr: 240, active: true },\n { id: 'c3', name: 'Grace Hopper', email: 'grace@navy.mil', tier: 'enterprise', mrr: 980, active: true },\n ]\n\n const columns = schemaToColumns(customersSchema)\n const source = createInMemoryDataSource(seed, customersSchema)\n\n let view = $state<ServerState<Customer>>({\n rows: [], total: 0, loading: false, saving: false, error: null,\n pageIndex: 0, pageSize: 10, pageCount: 1, sortModel: [], filterModel: {},\n })\n let editing = $state<Customer | null | undefined>(undefined)\n let genId = 4\n\n const controller = createServerDataSource(source, {\n pageSize: 10, optimistic: true, getRowId: (r) => r.id,\n onChange: (s) => (view = s),\n })\n controller.refresh()\n\n async function save({ mode, id, values }) {\n if (mode === 'create') { await controller.createRow({ id: `c${genId++}`, ...values }); controller.setPage(view.pageCount - 1) }\n else if (id) { await controller.updateRow(id, values) }\n editing = undefined\n }\n</script>\n\n<style>\n :global(body) {\n font-family: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n }\n</style>\n\n<button onclick={() => (editing = null)}>+ New customer</button>\n\n<SvGrid\n data={view.rows} {columns} loading={view.loading}\n fitColumns enableRowSummaries={false}\n sortable externalSort onSortingChange={(s) => controller.setSort(s)}\n filterable filterMode=\"row\" externalFilter\n onFiltersChange={(f) => controller.setFilter({\n global: f.global || undefined,\n columns: Object.fromEntries(f.columns.map((c) => [c.id, { operator: c.operator, value: c.value, valueTo: c.valueTo, selectedValues: c.selectedValues }])),\n })}\n onRowClick={(e) => (editing = e.row)}\n showPagination externalPagination\n rowCount={view.total} pageIndex={view.pageIndex} pageSize={view.pageSize}\n onPaginationChange={({ pageIndex, pageSize }) => pageSize !== view.pageSize ? controller.setPageSize(pageSize) : controller.setPage(pageIndex)}\n/>\n\n{#if editing !== undefined}\n <SvGridEditPanel schema={customersSchema} row={editing} presentation=\"modal\"\n onSubmit={save} onCancel={() => (editing = undefined)} />\n{/if}\n```\n\nThe `<style>` block is just a plain font reset - a fresh `npx sv create` app ships no CSS\nat all, so without it the page falls back to the browser's default serif font. `<SvGrid>`\nand `<SvGridEditPanel>` already theme their own borders, backgrounds, and hover states out\nof the box (via [`--sg-*` tokens](../../help/tokens.md) with built-in fallbacks) - font is\nthe one thing they intentionally inherit from the page rather than force, so it fits\nwhatever type your app already uses. If your app already sets a body font (or a\n[`--sg-font`](./theming.md) token), skip this block.\n\n---\n\n## See also\n\n- [SvGrid Studio overview](../studio.md)\n- [Concepts](./concepts.md) - the mental model + glossary\n- [Data binding](./data-binding.md) - the `ServerDataSource` contract in depth\n- [Edit forms & validation](./edit-forms.md)\n- [Troubleshooting & FAQ](./troubleshooting.md)\n"
4584
4584
  },
4585
4585
  {
4586
4586
  "slug": "enterprise/studio/i18n",
@@ -4860,7 +4860,7 @@ export const docs = [
4860
4860
  "path": "docs/help/ai-toolkit.md",
4861
4861
  "title": "AI Toolkit",
4862
4862
  "section": "Help",
4863
- "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.*`) | `@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 \"svgrid\": { \"command\": \"npx\", \"args\": [\"-y\", \"@svgrid/mcp\"] }\n }\n}\n```\n\nIt registers callable tools - `svgrid_search`, `svgrid_get`,\n`svgrid_check_code` and `svgrid_scaffold` - so the\nagent reads real demo source and current docs instead of guessing.\n\nFor **Studio** (turning a database or schema into a CRUD data-app), the\n[same server](../enterprise/studio/ai-generation.md) adds\n`svgrid_scaffold` and four `studio_*` project-model\ntools. 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| `svgrid_search`, `svgrid_get` | `@svgrid/mcp` | Build-time MCP tools your coding agent calls: find anything across docs, demos and the API, then read it in full. |\n| `svgrid_check_code` | `@svgrid/mcp` | Verifies generated code against the real API surface before you see it. |\n| `svgrid_scaffold`, `studio_*` | `@svgrid/mcp` | Studio generation tools (schema -> CRUD screen, project model). Studio tools are opt-in: set `SVGRID_MCP_STUDIO=1` or a licence key. |\n\nAuto-generated per-symbol reference: [`@svgrid/grid` · `ai.ts`](../reference/auto/svgrid-grid-ai.md).\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\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"
4863
+ "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.*`) | `@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 \"svgrid\": { \"command\": \"npx\", \"args\": [\"-y\", \"@svgrid/mcp\"] }\n }\n}\n```\n\nIt registers callable tools - `svgrid_search`, `svgrid_get`,\n`svgrid_check_code` and `svgrid_scaffold` - so the\nagent reads real demo source and current docs instead of guessing.\n\nFor **Studio** (turning a database or schema into a CRUD data-app), the\n[same server](../enterprise/studio/ai-generation.md) adds\n`svgrid_scaffold` and four `studio_*` project-model\ntools. 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| `svgrid_search`, `svgrid_get` | `@svgrid/mcp` | Build-time MCP tools your coding agent calls: find anything across docs, demos and the API, then read it in full. |\n| `svgrid_check_code` | `@svgrid/mcp` | Verifies generated code against the real API surface before you see it. |\n| `svgrid_scaffold`, `studio_*` | `@svgrid/mcp` | Studio generation tools (schema -> CRUD screen, project model). Studio tools are opt-in: set `SVGRID_MCP_STUDIO=1` or a licence key. Both need `@svgrid/enterprise` beside the server: `npm i -D @svgrid/mcp @svgrid/enterprise`, then run `npx svgrid-mcp`. |\n\nAuto-generated per-symbol reference: [`@svgrid/grid` · `ai.ts`](../reference/auto/svgrid-grid-ai.md).\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\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"
4864
4864
  },
4865
4865
  {
4866
4866
  "slug": "help/ai",
@@ -5343,7 +5343,7 @@ export const docs = [
5343
5343
  "path": "docs/help/comparison.md",
5344
5344
  "title": "Comparison: SvGrid vs AG Grid vs TanStack Table",
5345
5345
  "section": "Help",
5346
- "markdown": "\n# Comparison: SvGrid vs AG Grid vs TanStack Table\n\nThe three projects solve overlapping problems, and the right choice\ndepends on the framework you ship on, how much of the grid you want to\nbuild yourself, and your budget. Every number on this page is read from\nnpm or measured by a script in this repository on the date shown; the\nblock below is regenerated by `node tools/sync-guide-facts.mjs` and the\nper-competitor pages under [/compare/](https://svgrid.com/compare/) list\nthe sources for every feature claim.\n\n<!-- facts:start ag-grid,tanstack-table -->\n> **Facts, checked 12 Sep 2026.** `ag-grid-community` 36.1.0, MIT, last published 5 Aug 2026, 12,400,000 npm downloads in the 30 days to 10 Sep 2026. `@tanstack/svelte-table` 9.2.4, MIT, last published 28 Aug 2026, 231,000 npm downloads in the same window. `@svgrid/grid` 3.0.3, MIT, last published 11 Sep 2026, 16,900 npm downloads in the same window. Bundle, minified and gzipped, each package built alone with Svelte external: SvGrid 3.0.4 97.6 KB JS + 10.5 KB CSS (measured 22 Sep 2026); `ag-grid-community` 36.1.0 317.5 KB JS, no separate stylesheet (measured 12 Sep 2026); `@tanstack/svelte-table` 9.2.4 36.3 KB JS, no separate stylesheet, svelte external (measured 12 Sep 2026). AG Grid pricing, as its site states it: AG Grid Community is free under MIT. AG Grid Enterprise is listed at $999 USD per developer with one year of updates and Zendesk support; the Enterprise Bundle with AG Charts Enterprise is $1,498 USD per developer (https://www.ag-grid.com/license-pricing/, read 12 Sep 2026). TanStack Table pricing, as its site states it: TanStack Table is MIT and free; tanstack.com is sponsor-supported and offers Enterprise Support as private consulting and expert support, with no licence sold for the table (https://tanstack.com/table/latest, read 12 Sep 2026). SvGrid: MIT core; @svgrid/enterprise from $299 per developer per year (Indie, under $1M revenue), $599 for Enterprise. Side by side, with sources: [SvGrid vs AG Grid (community + enterprise)](https://svgrid.com/compare/ag-grid/), [SvGrid vs TanStack Table (Svelte)](https://svgrid.com/compare/tanstack-table/).\n<!-- facts:end -->\n\n## TL;DR\n\n| Project | Lives in | Ships | Licence |\n| --------------------- | ---------------------------------------- | ---------------------------------------------------------- | ----------------------------------------- |\n| **SvGrid** | Svelte 5 | Headless core + Svelte render component + Enterprise pack | MIT (Community) / commercial (Enterprise) |\n| **AG Grid Community** | React, Angular, Vue, plain JS | Full grid + renderer; Svelte through community integrations | MIT |\n| **AG Grid Enterprise**| same | Adds the set filter, range selection, master/detail, tree, grouping, pivot, charts, Excel export, server-side row model | Commercial, per developer |\n| **TanStack Table** | React, Vue, Svelte, Solid, Qwik, Lit, JS | Headless engine **only**; the Svelte adapter is Svelte 5 | MIT |\n\nThe bundle sizes and prices are in the facts block above, with the date\nthey were read and the method behind the sizes.\n\n## When SvGrid is the right choice\n\n- You're on **Svelte 5** and want a grid that uses the runtime's idioms\n (snippets for cells, `$state` for data, `$derived` for aggregates) -\n not a React-port pretending to be Svelte.\n- You want a **headless core you can render yourself** AND a\n default-styled component for the 80% case. Most \"headless\" libraries\n make you write the markup; most \"monolith\" libraries make you fight\n the markup. SvGrid does both in one package.\n- You need **clean theming via CSS custom properties** and a documented\n `--sg-*` token surface, not a hard-coded class soup.\n- You ship under **strict CSP** (no `eval`, no `new Function`, no\n inline scripts). SvGrid runs clean; AG Grid Community does too.\n TanStack Table is engine-only so the question doesn't apply.\n- You want **SSR markup that is meaningful before hydration** (good\n first paint, SEO, SvelteKit `+page.server` integration). SvGrid +\n TanStack Table both qualify. AG Grid renders client-side.\n- You want the features AG Grid sells as Enterprise - the set filter,\n range selection, master/detail, tree data, grouping with aggregation,\n integrated charts, flat server-side infinite scroll - **in the MIT\n core**. (Server-side grouping, tree, pivot and transactions are\n Enterprise in both.)\n\n## When AG Grid is the right choice\n\n- You're on **React, Angular, or Vue**, not Svelte. SvGrid is\n Svelte-only, and ag-grid.com points Svelte users to community-built\n integrations rather than an official package.\n- You need a push-based **viewport row model** for a real-time trading\n blotter, where the server streams the visible window over a socket.\n SvGrid's server-side row model covers sort, filter, grouping, tree,\n pivot, transactions and select-all across unloaded rows, and its\n real-time page merges deltas into loaded rows, but it does not have the\n viewport model.\n- You need **pluggable custom filter components or custom tool panels**.\n SvGrid's tool panel is a fixed Columns + Filters pair.\n- You need **AG Charts** for maps, network graphs, 3D or a canvas renderer.\n SvGrid's chart is SVG with decimation, and it ships 29 types, a configurable\n axis model, synchronized charts, a financial toolkit with indicator panes\n and drawing tools, a builder and PDF export in the MIT core; it does not\n draw maps.\n- You want a vendor with **a decade of enterprise procurement paperwork**\n already on file.\n\n## When TanStack Table is the right choice\n\n- You want a **rendering-framework-agnostic engine** so the same\n business logic powers React + Svelte + Solid surfaces in your\n monorepo.\n- You're already in the TanStack ecosystem (Query, Router, Form,\n Virtual) and want one mental model.\n- You're happy writing **all the markup yourself** - the row recycling,\n the keyboard map, the ARIA roles, the focus management, the\n drag-to-resize. That's the cost of \"engine only\".\n\n## Feature parity at a glance\n\nYes: shipped in that edition. Partial: needs extra work or a companion\npackage. No: not available. The AG Grid columns follow ag-grid.com's\nCommunity vs Enterprise page and its Set Filter, Tree Data and CSV export\npages; the dated list of those sources is on the\n[SvGrid vs AG Grid](https://svgrid.com/compare/ag-grid/) page, and the\nTanStack sources on [SvGrid vs TanStack Table](https://svgrid.com/compare/tanstack-table/).\n\n| | SvGrid Community | SvGrid Enterprise | AG Grid Community | AG Grid Enterprise | TanStack Table |\n| ------------------------------- | ---------------- | ----------------- | ----------------- | ------------------ | -------------- |\n| Headless core (engine only) | Yes | Yes | No | No | Yes |\n| Default render component | Yes (Svelte 5) | Yes | Yes (each framework) | Yes | No |\n| Sort (multi-column) | Yes | Yes | Yes | Yes | Yes (engine) |\n| Filter menu (operator + facet) | Yes | Yes | Partial (text and number filters; the set filter is Enterprise) | Yes | No |\n| Filter row | Yes | Yes | Yes | Yes | No |\n| Pagination | Yes | Yes | Yes | Yes | Yes (engine) |\n| Grouping + aggregation | Yes | Yes | No (Enterprise) | Yes | Yes (engine) |\n| Tree / expand-collapse rows | Yes | Yes | No (Enterprise) | Yes | Yes (engine) |\n| Master/detail | Yes | Yes | No (Enterprise) | Yes | No |\n| Cell range selection + copy/paste | Yes | Yes | No (Enterprise) | Yes | No |\n| Fill handle | Yes | Yes | No (Enterprise) | Yes | No |\n| Inline editing (typed editors) | Yes | Yes | Yes | Yes | No |\n| Column virtualization | Yes | Yes | Yes | Yes | No |\n| Row virtualization | Yes | Yes | Yes | Yes | Partial (pair with TanStack Virtual) |\n| Column pinning (left/right) | Yes | Yes | Yes | Yes | No |\n| Column tool panel | Yes | Yes | No (Enterprise) | Yes | No |\n| Context menu | Yes | Yes | No (Enterprise) | Yes | No |\n| Fit-to-width with shrink | Yes | Yes | Partial | Yes | No |\n| WAI-ARIA grid pattern | Yes | Yes | Yes | Yes | No (your markup) |\n| Server-side data, flat (sort / filter / paging / infinite scroll) | Yes (built in) | Yes (built in) | Yes (infinite row model) | Yes (built in) | Partial (manual flags) |\n| Server-side row model (lazy grouping, tree, pivot, transactions, select-all across unloaded rows) | No (Enterprise) | Yes (built in) | No (Enterprise) | Yes (built in) | No |\n| Global search over server-side data | Yes (`filterModel.global`) | Yes | No (the quick filter is client-side only) | No | n/a |\n| CSP-clean (no eval, no inline) | Yes | Yes | Yes | Yes | n/a |\n| Meaningful SSR markup | Yes | Yes | No | No | depends on your markup |\n| CSV / TSV / JSON export | Yes | Yes | Yes (CSV) | Yes | No |\n| Excel (.xlsx) / PDF export | No | Yes | No | Yes (Excel) | No |\n| Print | No | Yes | No | No | No |\n| Excel / CSV import | No | Yes | No | No | No |\n| In-grid AI helpers (bring your own model) | Yes | Yes | No | No | No |\n| MCP server for AI assistants | Yes (`@svgrid/mcp`) | Yes | Yes (`ag-mcp`) | Yes | No (agent skills ship in the package) |\n| Pivot table | No | Yes | No | Yes | No (custom) |\n| Integrated charts | Yes (29 types, builder, financial toolkit) | Yes | No | Yes (AG Charts) | No |\n| Support response time | GitHub (best-effort) | 1 business day; 1h sev-1 on Enterprise | GitHub (best-effort) | no number published | GitHub; paid Enterprise Support offered |\n| Theming via CSS variables | Yes (`--sg-*`) | Yes | Yes (theme builder) | Yes | n/a |\n| Source button per demo | Yes (gallery) | Yes | No | No | No |\n\n## Bundle size\n\nThe facts block at the top of the page has the gzipped size of each\npackage, measured by the same method: each package built alone with Vite\nin library mode, minified, gzip level 9, Svelte external because it is a\npeer dependency. SvGrid's own number comes from `pnpm size:json`\n(`packages/grid/scripts/measure-size.mjs`), the competitors' from\n`pnpm competitors:measure`; both write `docs/_data` and the block is\nregenerated from there, so the page cannot quote a size nobody measured.\n\nWhat the SvGrid number contains: the whole render component -\nvirtualization, Excel-style filters, inline editing, grouping, tree,\nmaster/detail, cell selection and accessibility - in one import, plus a\nseparate stylesheet. Charts, date and time editors, menus and export split\ninto `import()` chunks that load on demand rather than shipping in the\ninitial bundle; the headless core on its own is a small fraction of the\nrender component, and `@svgrid/enterprise` features are separate subpath\nimports. The per-entry breakdown, including the headless core and the\nlazy chunks, is in the [bundle size reference](./bundle-size.md).\n\nWhat the AG Grid number contains: `createGrid` with every Community\nmodule registered and the Quartz theme, whose CSS ships inside the script\nunder the theming API, so there is no separate stylesheet to add. The\nTanStack number is the whole Svelte adapter with its engine: no\nvirtualizer, filter UI or editors, because a headless library has none.\n\n## Measured performance\n\nA vendor's own benchmark is worth nothing unless you can rerun it, so the\nharness is in the repository and takes an adapter per grid. Adding another\ngrid is about forty lines in `examples/src/bench/adapters.ts` and nothing\nelse.\n\n```bash\n# headless, prints the table and writes tests/perf/.last-compare.json\nBENCH_REPEATS=7 BENCH_GRIDS=svgrid,aggrid,svar,tanstack pnpm bench:compare\n\n# move that run into docs/_data/competitors.json, then regenerate this page\nnode tools/record-benchmarks.mjs && node tools/sync-guide-facts.mjs\n\n# or drive it by hand\npnpm --filter @svgrid/grid-example-gallery dev # then open /bench.html\n```\n\nEach operation is timed until the grid's DOM reflects it, plus one frame:\n\n- **Mount** - fresh container, create the grid, until rows are painted.\n- **Sort** - change the sort on one column, until the new order is painted.\n Text sorts `name` (tens of thousands of distinct values), numeric sorts\n `amount`.\n- **Filter** - apply a `contains` filter to one column.\n- **Scroll** - 180 frames at 60 px/frame, sampling frame intervals.\n\n### Results\n\nThe table is generated from the ledger by `tools/sync-guide-facts.mjs`;\nthe versions and the date are part of it.\n\n<!-- bench:start -->\nMeasured 12 Sep 2026 with `pnpm bench:compare` (tests/perf/compare.spec.ts, recorded by tools/record-benchmarks.mjs): 100,000 rows x 9 columns; 1000 x 520 px container, 32 px rows, 140 px columns, each grid with its default theme; fastest of 7 samples per operation, each grid in a fresh page; Chromium through Playwright, headless; win32 10.0.26200 x64, Intel(R) Core(TM) Ultra 7 255U; a developer workstation, not a dedicated bench rig. Lower is better.\n\n| Operation | SvGrid | AG Grid Community | SVAR Svelte DataGrid | TanStack Table, minimal DOM |\n| --- | --- | --- | --- | --- |\n| Mount | 75.5 ms | 51.4 ms | 11.4 ms | 196 ms |\n| Sort, text column | 149 ms | 282.8 ms | 187.2 ms | 466 ms |\n| Sort, numeric column | 115.7 ms | 270 ms | 165.1 ms | 374.6 ms |\n| Filter, one column (indicative only, see below) | 99.6 ms | 170.4 ms | 32.6 ms | 111.7 ms |\n| Scroll, p95 frame | 34.6 ms | 35.9 ms | 34.8 ms | 35.3 ms |\n| Frames dropped while scrolling, of 180 | 0 | 0 | 0 | 1 |\n| Rows kept in the DOM (virtualization on) | 27 | 35 | 19 | 27 |\n\nVersions: `@svgrid/grid` 3.0.3, `ag-grid-community` 36.1.0, `wx-svelte-grid` 2.7.0, `@tanstack/svelte-table` 9.2.4.\n<!-- bench:end -->\n\nRead that as: **SvGrid sorts faster than AG Grid Community and SVAR on both\ncolumn types, scrolling is level across all four, and mounting is where the\nlighter grids are ahead** - SVAR paints its first rows fastest, AG Grid is\nahead of SvGrid, and the TanStack renderer pays for building a row object\nper record before it can draw anything. Anyone claiming a single headline\nnumber for a grid comparison is selling something.\n\n**The filter row is not a like-for-like comparison and is shown only because\nhiding a number we win looks worse than explaining it.** The grids' single-\ncolumn filter APIs differ: the harness drives AG Grid's quick filter, which\nsearches every column and is strictly more work than the single-column\n`contains` SvGrid performs, while SVAR and TanStack run a predicate over the\none column. Do not read a ratio into that row.\n\n**The TanStack column is an engine plus the least DOM an app could draw.**\nTanStack Table renders nothing, so its adapter draws the row model through\na fixed-height windowed table with no virtualizer library, no filter UI and\nno editors. Its mount and sort numbers include creating the engine's row\nobjects for every record; the grids around it do the same work inside their\nown models.\n\nSorting is where SvGrid's distance comes from, and it is not subtle: SvGrid\nresolves each sort clause once and precomputes a key array, and for a\ncolumn whose distinct values are far fewer than its rows it collates the\ndistinct values once and sorts by rank. Mounting is the other direction -\nsee the accounting in [benchmarks](./benchmarks.md).\n\nTwo methodology notes, both of which were bugs in this harness before they\nwere notes, and both of which changed the numbers by more than any code\nchange did:\n\n- **Each grid runs in a fresh page.** They used to run one after another in\n the same one, where the first warmed the JIT, fragmented the heap and\n left a hundred thousand rows of garbage for the next. Back-to-back runs\n on an idle machine disagreed with themselves by a factor of two.\n Run-to-run variance is now a few percent.\n- **Operations are timed until the DOM changes, not for a fixed two\n frames.** The old fixed wait put a two-frame floor under every number -\n an operation that recomputed nothing at all measured the floor, and a\n mount whose rows were already in the DOM measured pure waiting - which\n flattered whichever grid was slower.\n- **Each figure is the fastest of the samples, not the median.** Noise\n only ever adds time, so the minimum is the cleanest estimate of what the\n machine can do, and medians on this box drifted by a third between\n consecutive runs - more than most of the differences being measured.\n Applied identically to every grid.\n\nWhat this table deliberately does not include:\n\n- **Features the grids do not measure the same way.** Grouping, pivot,\n editing and export all differ enough in shape that a single timing would\n compare two different amounts of work. The filter row above is the one\n case we publish anyway, labelled, rather than quietly dropping a result\n that happens to favour us.\n- **Any grid we cannot license for this.** The harness is grid-agnostic on\n purpose, but some commercial grids licence their software in ways that\n restrict using it to produce competitive claims. Where that is the case\n we do not ship an adapter, and we would rather have an obviously\n incomplete table than a legally dubious one. Run your own shortlist.\n- **Your workload.** One dataset shape, one machine, one browser, default\n configuration for every grid. If a grid decision rides on this, clone the\n harness and put your own data in it.\n\n## Migrating from AG Grid\n\nThe most common starting point. See\n[Migrating from AG Grid](./migrating-from-ag-grid.md) for a side-by-side\ntranslation of column defs, features, filtering, editing, and the\nimperative API. The example that guide is built around runs here:\n\n<div data-docs-demo=\"139-migration-from-ag-grid\" data-height=\"560\"></div>\n\n## Migrating from TanStack Table\n\nThe map is one-to-one - SvGrid's headless core speaks the vocabulary\nTanStack Table v9 introduced. The big differences:\n\n- Replace `createTable(opts)` with `createSvGrid(opts)`. Same state\n machine, same features object.\n- Replace the row-model factories with the same names from\n `@svgrid/grid`.\n- The render layer changes - TanStack hands you `FlexRender` + the\n row model; SvGrid lets you keep that headless approach OR drop in\n the default `<SvGrid>` component.\n\nThe headless demo below drives a plain `<table>` from `createSvGrid`, the\nway a TanStack app would:\n\n<div data-docs-demo=\"186-headless-table\" data-height=\"520\"></div>\n\nAnd the same rows through the render component, which is the part TanStack\nleaves to you. The example runs against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Order = {\n id: number\n customer: string\n region: string\n status: string\n amount: number\n }\n\n const orders: Order[] = [\n { id: 1, customer: 'Ada Lovelace', region: 'EMEA', status: 'open', amount: 1240 },\n { id: 2, customer: 'Grace Hopper', region: 'AMER', status: 'shipped', amount: 860 },\n { id: 3, customer: 'Linus Torvalds', region: 'EMEA', status: 'closed', amount: 2210 },\n { id: 4, customer: 'Radia Perlman', region: 'APAC', status: 'open', amount: 430 },\n { id: 5, customer: 'Barbara Liskov', region: 'AMER', status: 'pending', amount: 1780 },\n ]\n\n let rows = $state<Order[]>(orders)\n\n const columns: GridColumns<Order> = [\n { field: 'customer', header: 'Customer', width: 180 },\n { field: 'region', header: 'Region', width: 110 },\n { field: 'status', header: 'Status', width: 110 },\n { field: 'amount', header: 'Amount', width: 120, editorType: 'number', format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```svelte {runnable}\n<SvGrid data={rows} {columns} sortable filterable selectable />\n```\n\n## Pricing\n\nSvGrid Community is MIT - free for commercial use, no attribution\nrequired at runtime. SvGrid Enterprise is a per-developer licence: $599\nper developer per year for the enterprise grid or $999 with the\nspreadsheet and Studio, both for unlimited applications; see\n<https://svgrid.com/pricing/> for what each tier adds.\n\nAG Grid Community is MIT. AG Grid Enterprise is priced per developer with\none year of updates; the figure ag-grid.com listed on the date we read it\nis in the facts block at the top of this page, and the\n[SvGrid vs AG Grid](https://svgrid.com/compare/ag-grid/) page carries the\nlink. Compare what each paid tier adds before comparing prices: most of\nwhat AG Grid sells as Enterprise is in SvGrid's free core.\n\nTanStack Table is MIT and sponsor-funded; tanstack.com offers paid\nEnterprise Support as private consulting.\n\n## Frequently asked questions\n\n### What is the best data grid for Svelte 5?\n\nFor a Svelte-5-native grid with a batteries-included render component, SvGrid is\nbuilt around runes and snippets. TanStack Table is a strong headless-only\nchoice if you want to build the DOM layer yourself across frameworks, and its\nv9 Svelte adapter targets Svelte 5. AG Grid is the most feature-complete but\nlives in React, Angular and Vue first and reaches Svelte through\ncommunity-built integrations.\n\n### Is SvGrid a good AG Grid alternative?\n\nYes, for Svelte projects. SvGrid is a fraction of AG Grid Community's\nmeasured size on a Svelte page (the facts block has both numbers and the\nmethod), is MIT-licensed for commercial use, and offers `@svgrid/enterprise`\nfor export, print, import and pivot at a per-developer price. On the\nbenchmark above it sorts a hundred-thousand-row column faster on both\ncolumn types and scrolls level; AG Grid mounts quicker. It does not match\nevery AG Grid Enterprise feature - see the\n[missing-features list](./missing-features.md) for the gaps.\n\n### SvGrid vs TanStack Table - which should I pick?\n\nPick SvGrid if you want virtualization, Excel-style filters, selection, and\ninline editing working out of the box on Svelte 5. Pick TanStack Table if you\nwant a framework-agnostic headless engine and are happy to build the rendering,\nvirtualization, and editing UI yourself. Both are MIT-licensed.\n\n### Does the TanStack Svelte adapter support Svelte 5?\n\nYes. The `@tanstack/svelte-table` v9 line declares a Svelte 5 peer range\nand passes data through getters rather than stores; the facts block above\nshows the version read from npm. The earlier `createSvelteTable` constructor\nis gone in v9.\n\n### Does AG Grid have an MCP server like SvGrid?\n\nYes. `ag-mcp` (MIT) gives an assistant AG Grid's docs, examples and\nversion-specific upgrade prompts. `@svgrid/mcp` does the same for SvGrid\nand adds a code checker that validates props and imports against the real\nAPI surface, a scaffold tool and a preview. Neither is an in-grid feature;\nSvGrid's natural-language filter, smart fill and summarise helpers are, and\nthey ship free in `@svgrid/grid` with a model provider you register.\n\n### How big is the SvGrid bundle?\n\nThe facts block at the top of this page has the measured gzipped size of the\nfull `<SvGrid>` render component and its stylesheet, read from\n`docs/_data/svgrid-size.json`, which `pnpm size:json` writes. The\n[bundle size reference](./bundle-size.md) breaks it down by entry:\nthe headless core on its own, the core subpath, the charts, and the lazy\nchunks that load on demand.\n\n## See also\n\n- [Why headless?](../why-headless.md) - the design decision behind\n SvGrid's two-layer architecture.\n- [Migrating from AG Grid](./migrating-from-ag-grid.md) - the\n practical recipe.\n- [Migrating from TanStack Table](./migrating-from-tanstack-table.md) - the\n one-to-one API map.\n- [Benchmarks](./benchmarks.md) - how the numbers are produced and what\n they leave out.\n- [Enterprise feature pack](../enterprise/README.md) - what SvGrid charges for.\n- [Missing features](./missing-features.md) - the honest gap list\n versus AG Grid Enterprise.\n- [All comparisons](https://svgrid.com/compare/) - one page per grid, with\n dated sources for every claim.\n"
5346
+ "markdown": "\n# Comparison: SvGrid vs AG Grid vs TanStack Table\n\nThe three projects solve overlapping problems, and the right choice\ndepends on the framework you ship on, how much of the grid you want to\nbuild yourself, and your budget. Every number on this page is read from\nnpm or measured by a script in this repository on the date shown; the\nblock below is regenerated by `node tools/sync-guide-facts.mjs` and the\nper-competitor pages under [/compare/](https://svgrid.com/compare/) list\nthe sources for every feature claim.\n\n<!-- facts:start ag-grid,tanstack-table -->\n> **Facts, checked 12 Sep 2026.** `ag-grid-community` 36.1.0, MIT, last published 5 Aug 2026, 12,400,000 npm downloads in the 30 days to 10 Sep 2026. `@tanstack/svelte-table` 9.2.4, MIT, last published 28 Aug 2026, 231,000 npm downloads in the same window. `@svgrid/grid` 3.0.3, MIT, last published 11 Sep 2026, 16,900 npm downloads in the same window. Bundle, minified and gzipped, each package built alone with Svelte external: SvGrid 3.0.4 97.6 KB JS + 10.5 KB CSS (measured 22 Sep 2026); `ag-grid-community` 36.1.0 317.5 KB JS, no separate stylesheet (measured 12 Sep 2026); `@tanstack/svelte-table` 9.2.4 36.3 KB JS, no separate stylesheet, svelte external (measured 12 Sep 2026). AG Grid pricing, as its site states it: AG Grid Community is free under MIT. AG Grid Enterprise is listed at $999 USD per developer with one year of updates and Zendesk support; the Enterprise Bundle with AG Charts Enterprise is $1,498 USD per developer (https://www.ag-grid.com/license-pricing/, read 12 Sep 2026). TanStack Table pricing, as its site states it: TanStack Table is MIT and free; tanstack.com is sponsor-supported and offers Enterprise Support as private consulting and expert support, with no licence sold for the table (https://tanstack.com/table/latest, read 12 Sep 2026). SvGrid: MIT core; @svgrid/enterprise from $299 per developer per year (Indie, under $1M revenue), $599 for Enterprise. Side by side, with sources: [SvGrid vs AG Grid (community + enterprise)](https://svgrid.com/compare/ag-grid/), [SvGrid vs TanStack Table (Svelte)](https://svgrid.com/compare/tanstack-table/).\n<!-- facts:end -->\n\n## TL;DR\n\n| Project | Lives in | Ships | Licence |\n| --------------------- | ---------------------------------------- | ---------------------------------------------------------- | ----------------------------------------- |\n| **SvGrid** | Svelte 5 | Headless core + Svelte render component + Enterprise pack | MIT (Community) / commercial (Enterprise) |\n| **AG Grid Community** | React, Angular, Vue, plain JS | Full grid + renderer; Svelte through community integrations | MIT |\n| **AG Grid Enterprise**| same | Adds the set filter, range selection, master/detail, tree, grouping, pivot, charts, Excel export, server-side row model | Commercial, per developer |\n| **TanStack Table** | React, Vue, Svelte, Solid, Qwik, Lit, JS | Headless engine **only**; the Svelte adapter is Svelte 5 | MIT |\n\nThe bundle sizes and prices are in the facts block above, with the date\nthey were read and the method behind the sizes.\n\n## When SvGrid is the right choice\n\n- You're on **Svelte 5** and want a grid that uses the runtime's idioms\n (snippets for cells, `$state` for data, `$derived` for aggregates) -\n not a React-port pretending to be Svelte.\n- You want a **headless core you can render yourself** AND a\n default-styled component for the 80% case. Most \"headless\" libraries\n make you write the markup; most \"monolith\" libraries make you fight\n the markup. SvGrid does both in one package.\n- You need **clean theming via CSS custom properties** and a documented\n `--sg-*` token surface, not a hard-coded class soup.\n- You ship under **strict CSP** (no `eval`, no `new Function`, no\n inline scripts). SvGrid runs clean; AG Grid Community does too.\n TanStack Table is engine-only so the question doesn't apply.\n- You want **SSR markup that is meaningful before hydration** (good\n first paint, SEO, SvelteKit `+page.server` integration). SvGrid +\n TanStack Table both qualify. AG Grid renders client-side.\n- You want the features AG Grid sells as Enterprise - the set filter,\n range selection, master/detail, tree data, grouping with aggregation,\n integrated charts, flat server-side infinite scroll - **in the MIT\n core**. (Server-side grouping, tree, pivot and transactions are\n Enterprise in both.)\n\n## When AG Grid is the right choice\n\n- You're on **React, Angular, or Vue**, not Svelte. SvGrid is\n Svelte-only, and ag-grid.com points Svelte users to community-built\n integrations rather than an official package.\n- You need a push-based **viewport row model** for a real-time trading\n blotter, where the server streams the visible window over a socket.\n SvGrid's server-side row model covers sort, filter, grouping, tree,\n pivot, transactions and select-all across unloaded rows, and its\n real-time page merges deltas into loaded rows, but it does not have the\n viewport model.\n- You need **custom tool panels**. SvGrid's tool panel is a fixed\n Columns + Filters pair. Custom filter components are covered by a\n column's `customFilter`.\n- You need **AG Charts** for maps, network graphs, 3D or a canvas renderer.\n SvGrid's chart is SVG with decimation, and it ships 29 types, a configurable\n axis model, synchronized charts, a financial toolkit with indicator panes\n and drawing tools, a builder and PDF export in the MIT core; it does not\n draw maps.\n- You want a vendor with **a decade of enterprise procurement paperwork**\n already on file.\n\n## When TanStack Table is the right choice\n\n- You want a **rendering-framework-agnostic engine** so the same\n business logic powers React + Svelte + Solid surfaces in your\n monorepo.\n- You're already in the TanStack ecosystem (Query, Router, Form,\n Virtual) and want one mental model.\n- You're happy writing **all the markup yourself** - the row recycling,\n the keyboard map, the ARIA roles, the focus management, the\n drag-to-resize. That's the cost of \"engine only\".\n\n## Feature parity at a glance\n\nYes: shipped in that edition. Partial: needs extra work or a companion\npackage. No: not available. The AG Grid columns follow ag-grid.com's\nCommunity vs Enterprise page and its Set Filter, Tree Data and CSV export\npages; the dated list of those sources is on the\n[SvGrid vs AG Grid](https://svgrid.com/compare/ag-grid/) page, and the\nTanStack sources on [SvGrid vs TanStack Table](https://svgrid.com/compare/tanstack-table/).\n\n| | SvGrid Community | SvGrid Enterprise | AG Grid Community | AG Grid Enterprise | TanStack Table |\n| ------------------------------- | ---------------- | ----------------- | ----------------- | ------------------ | -------------- |\n| Headless core (engine only) | Yes | Yes | No | No | Yes |\n| Default render component | Yes (Svelte 5) | Yes | Yes (each framework) | Yes | No |\n| Sort (multi-column) | Yes | Yes | Yes | Yes | Yes (engine) |\n| Filter menu (operator + facet) | Yes | Yes | Partial (text and number filters; the set filter is Enterprise) | Yes | No |\n| Filter row | Yes | Yes | Yes | Yes | No |\n| Pagination | Yes | Yes | Yes | Yes | Yes (engine) |\n| Grouping + aggregation | Yes | Yes | No (Enterprise) | Yes | Yes (engine) |\n| Tree / expand-collapse rows | Yes | Yes | No (Enterprise) | Yes | Yes (engine) |\n| Master/detail | Yes | Yes | No (Enterprise) | Yes | No |\n| Cell range selection + copy/paste | Yes | Yes | No (Enterprise) | Yes | No |\n| Fill handle | Yes | Yes | No (Enterprise) | Yes | No |\n| Inline editing (typed editors) | Yes | Yes | Yes | Yes | No |\n| Column virtualization | Yes | Yes | Yes | Yes | No |\n| Row virtualization | Yes | Yes | Yes | Yes | Partial (pair with TanStack Virtual) |\n| Column pinning (left/right) | Yes | Yes | Yes | Yes | No |\n| Column tool panel | Yes | Yes | No (Enterprise) | Yes | No |\n| Context menu | Yes | Yes | No (Enterprise) | Yes | No |\n| Fit-to-width with shrink | Yes | Yes | Partial | Yes | No |\n| WAI-ARIA grid pattern | Yes | Yes | Yes | Yes | No (your markup) |\n| Server-side data, flat (sort / filter / paging / infinite scroll) | Yes (built in) | Yes (built in) | Yes (infinite row model) | Yes (built in) | Partial (manual flags) |\n| Server-side row model (lazy grouping, tree, pivot, transactions, select-all across unloaded rows) | No (Enterprise) | Yes (built in) | No (Enterprise) | Yes (built in) | No |\n| Global search over server-side data | Yes (`filterModel.global`) | Yes | No (the quick filter is client-side only) | No | n/a |\n| CSP-clean (no eval, no inline) | Yes | Yes | Yes | Yes | n/a |\n| Meaningful SSR markup | Yes | Yes | No | No | depends on your markup |\n| CSV / TSV / JSON export | Yes | Yes | Yes (CSV) | Yes | No |\n| Excel (.xlsx) / PDF export | No | Yes | No | Yes (Excel) | No |\n| Print | No | Yes | No | No | No |\n| Excel / CSV import | No | Yes | No | No | No |\n| In-grid AI helpers (bring your own model) | Yes | Yes | No | No | No |\n| MCP server for AI assistants | Yes (`@svgrid/mcp`) | Yes | Yes (`ag-mcp`) | Yes | No (agent skills ship in the package) |\n| Pivot table | No | Yes | No | Yes | No (custom) |\n| Integrated charts | Yes (29 types, builder, financial toolkit) | Yes | No | Yes (AG Charts) | No |\n| Support response time | GitHub (best-effort) | 1 business day; 1h sev-1 on Enterprise | GitHub (best-effort) | no number published | GitHub; paid Enterprise Support offered |\n| Theming via CSS variables | Yes (`--sg-*`) | Yes | Yes (theme builder) | Yes | n/a |\n| Source button per demo | Yes (gallery) | Yes | No | No | No |\n\n## Bundle size\n\nThe facts block at the top of the page has the gzipped size of each\npackage, measured by the same method: each package built alone with Vite\nin library mode, minified, gzip level 9, Svelte external because it is a\npeer dependency. SvGrid's own number comes from `pnpm size:json`\n(`packages/grid/scripts/measure-size.mjs`), the competitors' from\n`pnpm competitors:measure`; both write `docs/_data` and the block is\nregenerated from there, so the page cannot quote a size nobody measured.\n\nWhat the SvGrid number contains: the whole render component -\nvirtualization, Excel-style filters, inline editing, grouping, tree,\nmaster/detail, cell selection and accessibility - in one import, plus a\nseparate stylesheet. Charts, date and time editors, menus and export split\ninto `import()` chunks that load on demand rather than shipping in the\ninitial bundle; the headless core on its own is a small fraction of the\nrender component, and `@svgrid/enterprise` features are separate subpath\nimports. The per-entry breakdown, including the headless core and the\nlazy chunks, is in the [bundle size reference](./bundle-size.md).\n\nWhat the AG Grid number contains: `createGrid` with every Community\nmodule registered and the Quartz theme, whose CSS ships inside the script\nunder the theming API, so there is no separate stylesheet to add. The\nTanStack number is the whole Svelte adapter with its engine: no\nvirtualizer, filter UI or editors, because a headless library has none.\n\n## Measured performance\n\nA vendor's own benchmark is worth nothing unless you can rerun it, so the\nharness is in the repository and takes an adapter per grid. Adding another\ngrid is about forty lines in `examples/src/bench/adapters.ts` and nothing\nelse.\n\n```bash\n# headless, prints the table and writes tests/perf/.last-compare.json\nBENCH_REPEATS=7 BENCH_GRIDS=svgrid,aggrid,svar,tanstack pnpm bench:compare\n\n# move that run into docs/_data/competitors.json, then regenerate this page\nnode tools/record-benchmarks.mjs && node tools/sync-guide-facts.mjs\n\n# or drive it by hand\npnpm --filter @svgrid/grid-example-gallery dev # then open /bench.html\n```\n\nEach operation is timed until the grid's DOM reflects it, plus one frame:\n\n- **Mount** - fresh container, create the grid, until rows are painted.\n- **Sort** - change the sort on one column, until the new order is painted.\n Text sorts `name` (tens of thousands of distinct values), numeric sorts\n `amount`.\n- **Filter** - apply a `contains` filter to one column.\n- **Scroll** - 180 frames at 60 px/frame, sampling frame intervals.\n\n### Results\n\nThe table is generated from the ledger by `tools/sync-guide-facts.mjs`;\nthe versions and the date are part of it.\n\n<!-- bench:start -->\nMeasured 12 Sep 2026 with `pnpm bench:compare` (tests/perf/compare.spec.ts, recorded by tools/record-benchmarks.mjs): 100,000 rows x 9 columns; 1000 x 520 px container, 32 px rows, 140 px columns, each grid with its default theme; fastest of 7 samples per operation, each grid in a fresh page; Chromium through Playwright, headless; win32 10.0.26200 x64, Intel(R) Core(TM) Ultra 7 255U; a developer workstation, not a dedicated bench rig. Lower is better.\n\n| Operation | SvGrid | AG Grid Community | SVAR Svelte DataGrid | TanStack Table, minimal DOM |\n| --- | --- | --- | --- | --- |\n| Mount | 75.5 ms | 51.4 ms | 11.4 ms | 196 ms |\n| Sort, text column | 149 ms | 282.8 ms | 187.2 ms | 466 ms |\n| Sort, numeric column | 115.7 ms | 270 ms | 165.1 ms | 374.6 ms |\n| Filter, one column (indicative only, see below) | 99.6 ms | 170.4 ms | 32.6 ms | 111.7 ms |\n| Scroll, p95 frame | 34.6 ms | 35.9 ms | 34.8 ms | 35.3 ms |\n| Frames dropped while scrolling, of 180 | 0 | 0 | 0 | 1 |\n| Rows kept in the DOM (virtualization on) | 27 | 35 | 19 | 27 |\n\nVersions: `@svgrid/grid` 3.0.3, `ag-grid-community` 36.1.0, `wx-svelte-grid` 2.7.0, `@tanstack/svelte-table` 9.2.4.\n<!-- bench:end -->\n\nRead that as: **SvGrid sorts faster than AG Grid Community and SVAR on both\ncolumn types, scrolling is level across all four, and mounting is where the\nlighter grids are ahead** - SVAR paints its first rows fastest, AG Grid is\nahead of SvGrid, and the TanStack renderer pays for building a row object\nper record before it can draw anything. Anyone claiming a single headline\nnumber for a grid comparison is selling something.\n\n**The filter row is not a like-for-like comparison and is shown only because\nhiding a number we win looks worse than explaining it.** The grids' single-\ncolumn filter APIs differ: the harness drives AG Grid's quick filter, which\nsearches every column and is strictly more work than the single-column\n`contains` SvGrid performs, while SVAR and TanStack run a predicate over the\none column. Do not read a ratio into that row.\n\n**The TanStack column is an engine plus the least DOM an app could draw.**\nTanStack Table renders nothing, so its adapter draws the row model through\na fixed-height windowed table with no virtualizer library, no filter UI and\nno editors. Its mount and sort numbers include creating the engine's row\nobjects for every record; the grids around it do the same work inside their\nown models.\n\nSorting is where SvGrid's distance comes from, and it is not subtle: SvGrid\nresolves each sort clause once and precomputes a key array, and for a\ncolumn whose distinct values are far fewer than its rows it collates the\ndistinct values once and sorts by rank. Mounting is the other direction -\nsee the accounting in [benchmarks](./benchmarks.md).\n\nTwo methodology notes, both of which were bugs in this harness before they\nwere notes, and both of which changed the numbers by more than any code\nchange did:\n\n- **Each grid runs in a fresh page.** They used to run one after another in\n the same one, where the first warmed the JIT, fragmented the heap and\n left a hundred thousand rows of garbage for the next. Back-to-back runs\n on an idle machine disagreed with themselves by a factor of two.\n Run-to-run variance is now a few percent.\n- **Operations are timed until the DOM changes, not for a fixed two\n frames.** The old fixed wait put a two-frame floor under every number -\n an operation that recomputed nothing at all measured the floor, and a\n mount whose rows were already in the DOM measured pure waiting - which\n flattered whichever grid was slower.\n- **Each figure is the fastest of the samples, not the median.** Noise\n only ever adds time, so the minimum is the cleanest estimate of what the\n machine can do, and medians on this box drifted by a third between\n consecutive runs - more than most of the differences being measured.\n Applied identically to every grid.\n\nWhat this table deliberately does not include:\n\n- **Features the grids do not measure the same way.** Grouping, pivot,\n editing and export all differ enough in shape that a single timing would\n compare two different amounts of work. The filter row above is the one\n case we publish anyway, labelled, rather than quietly dropping a result\n that happens to favour us.\n- **Any grid we cannot license for this.** The harness is grid-agnostic on\n purpose, but some commercial grids licence their software in ways that\n restrict using it to produce competitive claims. Where that is the case\n we do not ship an adapter, and we would rather have an obviously\n incomplete table than a legally dubious one. Run your own shortlist.\n- **Your workload.** One dataset shape, one machine, one browser, default\n configuration for every grid. If a grid decision rides on this, clone the\n harness and put your own data in it.\n\n## Migrating from AG Grid\n\nThe most common starting point. See\n[Migrating from AG Grid](./migrating-from-ag-grid.md) for a side-by-side\ntranslation of column defs, features, filtering, editing, and the\nimperative API. The example that guide is built around runs here:\n\n<div data-docs-demo=\"139-migration-from-ag-grid\" data-height=\"560\"></div>\n\n## Migrating from TanStack Table\n\nThe map is one-to-one - SvGrid's headless core speaks the vocabulary\nTanStack Table v9 introduced. The big differences:\n\n- Replace `createTable(opts)` with `createSvGrid(opts)`. Same state\n machine, same features object.\n- Replace the row-model factories with the same names from\n `@svgrid/grid`.\n- The render layer changes - TanStack hands you `FlexRender` + the\n row model; SvGrid lets you keep that headless approach OR drop in\n the default `<SvGrid>` component.\n\nThe headless demo below drives a plain `<table>` from `createSvGrid`, the\nway a TanStack app would:\n\n<div data-docs-demo=\"186-headless-table\" data-height=\"520\"></div>\n\nAnd the same rows through the render component, which is the part TanStack\nleaves to you. The example runs against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Order = {\n id: number\n customer: string\n region: string\n status: string\n amount: number\n }\n\n const orders: Order[] = [\n { id: 1, customer: 'Ada Lovelace', region: 'EMEA', status: 'open', amount: 1240 },\n { id: 2, customer: 'Grace Hopper', region: 'AMER', status: 'shipped', amount: 860 },\n { id: 3, customer: 'Linus Torvalds', region: 'EMEA', status: 'closed', amount: 2210 },\n { id: 4, customer: 'Radia Perlman', region: 'APAC', status: 'open', amount: 430 },\n { id: 5, customer: 'Barbara Liskov', region: 'AMER', status: 'pending', amount: 1780 },\n ]\n\n let rows = $state<Order[]>(orders)\n\n const columns: GridColumns<Order> = [\n { field: 'customer', header: 'Customer', width: 180 },\n { field: 'region', header: 'Region', width: 110 },\n { field: 'status', header: 'Status', width: 110 },\n { field: 'amount', header: 'Amount', width: 120, editorType: 'number', format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```svelte {runnable}\n<SvGrid data={rows} {columns} sortable filterable selectable />\n```\n\n## Pricing\n\nSvGrid Community is MIT - free for commercial use, no attribution\nrequired at runtime. SvGrid Enterprise is a per-developer licence: $599\nper developer per year for the enterprise grid or $999 with the\nspreadsheet and Studio, both for unlimited applications; see\n<https://svgrid.com/pricing/> for what each tier adds.\n\nAG Grid Community is MIT. AG Grid Enterprise is priced per developer with\none year of updates; the figure ag-grid.com listed on the date we read it\nis in the facts block at the top of this page, and the\n[SvGrid vs AG Grid](https://svgrid.com/compare/ag-grid/) page carries the\nlink. Compare what each paid tier adds before comparing prices: most of\nwhat AG Grid sells as Enterprise is in SvGrid's free core.\n\nTanStack Table is MIT and sponsor-funded; tanstack.com offers paid\nEnterprise Support as private consulting.\n\n## Frequently asked questions\n\n### What is the best data grid for Svelte 5?\n\nFor a Svelte-5-native grid with a batteries-included render component, SvGrid is\nbuilt around runes and snippets. TanStack Table is a strong headless-only\nchoice if you want to build the DOM layer yourself across frameworks, and its\nv9 Svelte adapter targets Svelte 5. AG Grid is the most feature-complete but\nlives in React, Angular and Vue first and reaches Svelte through\ncommunity-built integrations.\n\n### Is SvGrid a good AG Grid alternative?\n\nYes, for Svelte projects. SvGrid is a fraction of AG Grid Community's\nmeasured size on a Svelte page (the facts block has both numbers and the\nmethod), is MIT-licensed for commercial use, and offers `@svgrid/enterprise`\nfor export, print, import and pivot at a per-developer price. On the\nbenchmark above it sorts a hundred-thousand-row column faster on both\ncolumn types and scrolls level; AG Grid mounts quicker. It does not match\nevery AG Grid Enterprise feature - see the\n[missing-features list](./missing-features.md) for the gaps.\n\n### SvGrid vs TanStack Table - which should I pick?\n\nPick SvGrid if you want virtualization, Excel-style filters, selection, and\ninline editing working out of the box on Svelte 5. Pick TanStack Table if you\nwant a framework-agnostic headless engine and are happy to build the rendering,\nvirtualization, and editing UI yourself. Both are MIT-licensed.\n\n### Does the TanStack Svelte adapter support Svelte 5?\n\nYes. The `@tanstack/svelte-table` v9 line declares a Svelte 5 peer range\nand passes data through getters rather than stores; the facts block above\nshows the version read from npm. The earlier `createSvelteTable` constructor\nis gone in v9.\n\n### Does AG Grid have an MCP server like SvGrid?\n\nYes. `ag-mcp` (MIT) gives an assistant AG Grid's docs, examples and\nversion-specific upgrade prompts. `@svgrid/mcp` does the same for SvGrid\nand adds a code checker that validates props and imports against the real\nAPI surface, a scaffold tool and a preview. Neither is an in-grid feature;\nSvGrid's natural-language filter, smart fill and summarise helpers are, and\nthey ship free in `@svgrid/grid` with a model provider you register.\n\n### How big is the SvGrid bundle?\n\nThe facts block at the top of this page has the measured gzipped size of the\nfull `<SvGrid>` render component and its stylesheet, read from\n`docs/_data/svgrid-size.json`, which `pnpm size:json` writes. The\n[bundle size reference](./bundle-size.md) breaks it down by entry:\nthe headless core on its own, the core subpath, the charts, and the lazy\nchunks that load on demand.\n\n## See also\n\n- [Why headless?](../why-headless.md) - the design decision behind\n SvGrid's two-layer architecture.\n- [Migrating from AG Grid](./migrating-from-ag-grid.md) - the\n practical recipe.\n- [Migrating from TanStack Table](./migrating-from-tanstack-table.md) - the\n one-to-one API map.\n- [Benchmarks](./benchmarks.md) - how the numbers are produced and what\n they leave out.\n- [Enterprise feature pack](../enterprise/README.md) - what SvGrid charges for.\n- [Missing features](./missing-features.md) - the honest gap list\n versus AG Grid Enterprise.\n- [All comparisons](https://svgrid.com/compare/) - one page per grid, with\n dated sources for every claim.\n"
5347
5347
  },
5348
5348
  {
5349
5349
  "slug": "help/conditional-form-schema",
@@ -5490,7 +5490,7 @@ export const docs = [
5490
5490
  "path": "docs/help/filtering/custom-column-filters.md",
5491
5491
  "title": "Custom column filters",
5492
5492
  "section": "Filtering",
5493
- "markdown": "# Custom column filters\n\nWhen the built-in operators and the set-filter pattern are not enough,\ntake over the filter pipeline.\n<div data-docs-demo=\"03-excel-filters\" data-height=\"540\"></div>\n\n## Option A - supply a `filterFn`\n\nThe simplest extension: register your own filter function and reference it\nper column.\n\n```ts\n// types\ndeclare module '@svgrid/grid' {\n interface FilterFnsRegistry {\n inListCSV: (value: unknown, query: string) => boolean\n }\n}\n\n// register\nimport { filterFns } from '@svgrid/grid'\n;(filterFns as any).inListCSV = (value: unknown, query: string) => {\n const items = String(query).split(',').map((s) => s.trim().toLowerCase())\n return items.includes(String(value ?? '').toLowerCase())\n}\n\n// use\nconst columnFilters = [{ id: 'status', value: 'active,pending', fn: 'inListCSV' }]\n```\n\n`filterFns` is a plain object so monkey-patching works - but the\n`FilterFnsRegistry` module augmentation is what tells TypeScript about the\nnew key.\n\n## Option B - control the entire filter pipeline\n\nPass `externalFilter={true}` and feed the grid a pre-filtered array. The\ngrid still records the in-UI filter state (so the menu and chips light\nup correctly) but does **not** filter rows itself - you do, in response\nto the `onFiltersChange` callback. This is the path server-side data\nsources, large remote datasets, and tree data take.\n\n```svelte\n<script lang=\"ts\">\n let rawRows = $state<Person[]>([])\n let filters = $state<{ id: string; operator: string; value: string }[]>([])\n const filtered = $derived(myComplexFilter(rawRows, filters))\n</script>\n\n<SvGrid\n data={filtered}\n {columns}\n features={features}\n filterMode=\"menu\"\n externalFilter={true}\n onFiltersChange={(next) => (filters = next.columns)}\n/>\n```\n\nThe same pattern exists for sort (`externalSort` + `onSortingChange`)\nand works on the same grid - see the [server-side demo](../../../examples/src/demos/09-server-side.svelte).\n\nIf you only want a single pre-filtered array and don't need the grid's\nfilter UI at all, pass `filterMode=\"none\"` instead and skip the\ncallbacks entirely.\n\n## Option C - custom column UI\n\nRender a custom header (via `header: () => renderSnippet(...)`) that\nincludes your own filter widget. Update controlled state from the widget;\nthe grid will react. See [Custom header components](../columns/custom-header-components.md).\n\n## A predicate the operators cannot express\n\nThere is no per-column `filterFn`, so a custom rule lives outside the grid:\nnarrow the array, hand the result to `data`. Everything downstream - sort,\nselection, the row count - follows the filtered set.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, renderSnippet, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n active: boolean\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000, active: true },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000, active: true },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000, active: false },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000, active: true },\n ]\n\n let query = $state('')\n\n // Match on initials: \"gh\" finds Grace Hopper. No operator expresses this.\n function initials(p: Person) {\n return p.name.split(' ').map((w) => w[0]!.toLowerCase()).join('')\n }\n\n const shown = $derived(\n query.trim() ? people.filter((p) => initials(p).startsWith(query.trim().toLowerCase())) : people,\n )\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'department', header: 'Department', width: 170 },\n ]\n</script>\n\n<input placeholder=\"Try: gh\" bind:value={query} />\n\n<SvGrid data={shown} {columns} sortable />\n```\n\n\n## Combining with the built-in filters\n\nYour predicate and the column filters compose without knowing about each\nother: yours narrows `data`, the grid narrows what it renders. Order does not\nmatter because both are pure.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, renderSnippet, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n active: boolean\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000, active: true },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000, active: true },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000, active: false },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000, active: true },\n ]\n\n let activeOnly = $state(true)\n\n const shown = $derived(activeOnly ? people.filter((p) => p.active) : people)\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 180 },\n { field: 'department', header: 'Department', width: 160 },\n { field: 'city', header: 'City', width: 140 },\n ]\n</script>\n\n<label><input type=\"checkbox\" bind:checked={activeOnly} /> Active only</label>\n\n<SvGrid data={shown} {columns} filterable filterMode=\"row\" sortable />\n```\n\n## See also\n\n- [Filter API](./filter-api.md)\n- [Set filter](./set-filter.md)\n"
5493
+ "markdown": "# Custom column filters\n\nWhen the built-in operators and the set-filter pattern are not enough,\ngive the column its own filter, or take over the filter pipeline.\n<div data-docs-demo=\"03-excel-filters\" data-height=\"540\"></div>\n\n## Your own filter control: `customFilter`\n\nA column's `customFilter` replaces the operator condition with your own\nmodel and control: a range slider that holds `[min, max]`, a tag picker that\nholds `string[]`, a \"last N days\" widget that holds a number.\n\n- `predicate(model)` returns the row test. It runs once per filter change,\n so parse dates or build a `Set` there, not inside the returned function.\n The test gets the column's value and the row's data.\n- `menu` is a snippet for the filter menu and the tool panel's Filters tab.\n `floating` is one for the floating filter row. Each falls back to the\n other, so one snippet can serve all three.\n- The snippet receives `{ column, model, setModel, surface, values, close }`.\n `setModel(null)` clears the filter. `values()` returns the column's\n distinct values under the other columns' filters, the same list the Values\n checklist shows.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type CustomFilterContext, type GridColumns } from '@svgrid/grid'\n\n type Deal = { id: number; company: string; stage: string; amount: number }\n\n const deals: Deal[] = [\n { id: 1, company: 'Northwind', stage: 'Proposal', amount: 4200 },\n { id: 2, company: 'Contoso', stage: 'Negotiation', amount: 18500 },\n { id: 3, company: 'Fabrikam', stage: 'Closed won', amount: 32000 },\n { id: 4, company: 'Tailspin', stage: 'Proposal', amount: 950 },\n { id: 5, company: 'Litware', stage: 'Negotiation', amount: 27400 },\n { id: 6, company: 'Adventure', stage: 'Closed won', amount: 12800 },\n ]\n\n type Range = [number, number]\n\n const columns: GridColumns<Deal> = [\n { field: 'company', header: 'Company', width: 160 },\n { field: 'stage', header: 'Stage', width: 140 },\n {\n field: 'amount',\n header: 'Amount',\n width: 220,\n format: { type: 'currency', currency: 'USD' },\n customFilter: {\n predicate: ([min, max]: Range) => (value) => {\n const n = Number(value)\n return n >= min && n <= max\n },\n menu: amountRange,\n floating: amountRange,\n },\n },\n ]\n</script>\n\n{#snippet amountRange(f: CustomFilterContext<Deal, Range>)}\n {@const max = f.model?.[1] ?? 40000}\n <label style=\"display: flex; align-items: center; gap: 6px; font-size: 12px;\">\n <input\n type=\"range\"\n min=\"0\"\n max=\"40000\"\n step=\"1000\"\n value={max}\n oninput={(e) => {\n const next = Number(e.currentTarget.value)\n f.setModel(next >= 40000 ? null : [0, next])\n }}\n />\n <span>{max >= 40000 ? 'Any' : `up to ${max.toLocaleString()}`}</span>\n </label>\n{/snippet}\n\n<SvGrid data={deals} {columns} filterable filterMode=\"row\" />\n```\n\nThe model is ANDed with the column's operator condition and its Values\nchecklist, and is part of the rest of the filter state:\n\n- `api.setCustomFilter(columnId, model)` sets it, `null` clears it, and\n `api.getCustomFilter(columnId)` reads it. `api.clearFilter` and\n `api.clearAllFilters` clear it with everything else.\n- `api.getState()` returns `customFilters` while any model is set, and\n `api.setState({ customFilters })` restores them. Keep the model\n JSON-serializable if you save views.\n- `onFiltersChange` reports active models under `custom`, keyed by column id.\n With `externalFilter` the grid only records the model, so a server\n translates it into its query; `createServerDataSource` forwards it as\n `filterModel.custom`.\n- An inactive model filters nothing and does not light the funnel. By\n default `null`, `undefined`, `''` and `[]` are inactive; pass\n `isActive(model)` for anything else.\n- `showValues: false` drops the Values checklist from the menu when it adds\n nothing, as for a slider over thousands of distinct prices.\n\nA `customFilter` with only `predicate` keeps the built-in UI; the model is\nthen set only through the API.\n\n## Option A - supply a `filterFn`\n\nThe simplest extension: register your own filter function and reference it\nper column.\n\n```ts\n// types\ndeclare module '@svgrid/grid' {\n interface FilterFnsRegistry {\n inListCSV: (value: unknown, query: string) => boolean\n }\n}\n\n// register\nimport { filterFns } from '@svgrid/grid'\n;(filterFns as any).inListCSV = (value: unknown, query: string) => {\n const items = String(query).split(',').map((s) => s.trim().toLowerCase())\n return items.includes(String(value ?? '').toLowerCase())\n}\n\n// use\nconst columnFilters = [{ id: 'status', value: 'active,pending', fn: 'inListCSV' }]\n```\n\n`filterFns` is a plain object so monkey-patching works - but the\n`FilterFnsRegistry` module augmentation is what tells TypeScript about the\nnew key.\n\n## Option B - control the entire filter pipeline\n\nPass `externalFilter={true}` and feed the grid a pre-filtered array. The\ngrid still records the in-UI filter state (so the menu and chips light\nup correctly) but does **not** filter rows itself - you do, in response\nto the `onFiltersChange` callback. This is the path server-side data\nsources, large remote datasets, and tree data take.\n\n```svelte\n<script lang=\"ts\">\n let rawRows = $state<Person[]>([])\n let filters = $state<{ id: string; operator: string; value: string }[]>([])\n const filtered = $derived(myComplexFilter(rawRows, filters))\n</script>\n\n<SvGrid\n data={filtered}\n {columns}\n features={features}\n filterMode=\"menu\"\n externalFilter={true}\n onFiltersChange={(next) => (filters = next.columns)}\n/>\n```\n\nThe same pattern exists for sort (`externalSort` + `onSortingChange`)\nand works on the same grid - see the [server-side demo](../../../examples/src/demos/09-server-side.svelte).\n\nIf you only want a single pre-filtered array and don't need the grid's\nfilter UI at all, pass `filterMode=\"none\"` instead and skip the\ncallbacks entirely.\n\n## Option C - custom column UI\n\nRender a custom header (via `header: () => renderSnippet(...)`) that\nincludes your own filter widget. Update controlled state from the widget;\nthe grid will react. See [Custom header components](../columns/custom-header-components.md).\n\n## A control that lives outside the grid\n\nWhen the filter control sits outside the grid, narrowing the array is the\nshortest path: filter it, hand the result to `data`. Everything downstream -\nsort, selection, the row count - follows the filtered set. For a control in\nthe grid's own filter surfaces, use `customFilter` above.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, renderSnippet, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n active: boolean\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000, active: true },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000, active: true },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000, active: false },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000, active: true },\n ]\n\n let query = $state('')\n\n // Match on initials: \"gh\" finds Grace Hopper. No operator expresses this.\n function initials(p: Person) {\n return p.name.split(' ').map((w) => w[0]!.toLowerCase()).join('')\n }\n\n const shown = $derived(\n query.trim() ? people.filter((p) => initials(p).startsWith(query.trim().toLowerCase())) : people,\n )\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'department', header: 'Department', width: 170 },\n ]\n</script>\n\n<input placeholder=\"Try: gh\" bind:value={query} />\n\n<SvGrid data={shown} {columns} sortable />\n```\n\n\n## Combining with the built-in filters\n\nYour predicate and the column filters compose without knowing about each\nother: yours narrows `data`, the grid narrows what it renders. Order does not\nmatter because both are pure.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, renderSnippet, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n active: boolean\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000, active: true },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000, active: true },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000, active: false },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000, active: true },\n ]\n\n let activeOnly = $state(true)\n\n const shown = $derived(activeOnly ? people.filter((p) => p.active) : people)\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 180 },\n { field: 'department', header: 'Department', width: 160 },\n { field: 'city', header: 'City', width: 140 },\n ]\n</script>\n\n<label><input type=\"checkbox\" bind:checked={activeOnly} /> Active only</label>\n\n<SvGrid data={shown} {columns} filterable filterMode=\"row\" sortable />\n```\n\n## See also\n\n- [Filter API](./filter-api.md)\n- [Set filter](./set-filter.md)\n"
5494
5494
  },
5495
5495
  {
5496
5496
  "slug": "help/filtering/date-filter",
@@ -5504,7 +5504,7 @@ export const docs = [
5504
5504
  "path": "docs/help/filtering/filter-api.md",
5505
5505
  "title": "Filter API",
5506
5506
  "section": "Filtering",
5507
- "markdown": "# Filter API\n\nTwo surfaces - pick based on whether the caller is inside or outside the\ncomponent that owns the grid state.\n<div data-docs-demo=\"64-filter-between-operator\" data-height=\"540\"></div>\n\n## `SvGridApi` (imperative)\n\nAvailable via `<SvGrid onApiReady={(api) => /* … */}>`.\n\n```ts\napi.setFilter(\n columnId: string,\n filter: { operator: SvGridFilterOperator; value?: string } | null,\n): void\n\napi.clearFilter(columnId: string): void\n```\n\n`SvGridFilterOperator` is the union: `'contains' | 'equals' | 'startsWith'\n| 'greaterThan' | 'lessThan' | 'isBlank'`.\n\nPassing `null` clears the filter on that column.\n\n```ts\napi.setFilter('status', { operator: 'equals', value: 'active' })\napi.setFilter('age', { operator: 'greaterThan', value: '30' })\napi.setFilter('email', { operator: 'contains', value: '@example.com' })\napi.setFilter('department', null) // clear\napi.clearFilter('age') // same effect, sugared\n```\n\nThe wrapper internally writes into the `columnFilters` state slice and\nre-runs the filtered row model.\n\n## Observe filter state\n\nFor history, persistence, or server-side, subscribe via\n`onFiltersChange`:\n\n```svelte\n<script lang=\"ts\">\n let filters = $state<Array<{\n id: string\n operator: string\n value: string\n selectedValues?: Array<string>\n }>>([])\n let globalFilter = $state('')\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n onFiltersChange={(next) => { filters = next.columns; globalFilter = next.global }}\n/>\n```\n\nThe callback receives a consolidated `{ global, columns }` shape so the\nthree internal stores (global search, per-column operator filter, facet\nchecklist) collapse into one payload you can serialise.\n\nFor server-side / external pipelines, also set `externalFilter={true}`\nso the grid doesn't filter the rows locally - see [Custom column\nfilters](./custom-column-filters.md).\n\n## Read the current filters\n\nFrom the imperative API there is no `getFilters()` getter today - read the\nunderlying state via the grid instance:\n\n```ts\nconst grid = api as unknown as { /* internals are not part of the public type */ }\n```\n\nThis is not a stable surface. If you need to read filters from outside,\ncontrol the state.\n\n## Setting a filter from code\n\n`setFilter(columnId, { operator, value })` applies the same filter the menu\nwould, so a \"show me only Platform\" button and the UI end up in one state rather\nthan two.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n let api = $state<SvGridApi<{}, Person> | null>(null)\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'department', header: 'Department', width: 160 },\n { field: 'salary', header: 'Salary', width: 140,\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<div>\n <button type=\"button\" onclick={() => api?.setFilter('department', { operator: 'equals', value: 'Platform' })}>\n Platform only\n </button>\n <button type=\"button\" onclick={() => api?.setFilter('department', null)}>Clear</button>\n</div>\n\n<SvGrid data={people} {columns} filterable filterMode=\"menu\" onApiReady={(next) => (api = next)} />\n```\n\n\n## Reading what is applied\n\n`getFilters()` returns the live model, which is what you persist to a URL or a\nsaved view. Apply a filter above and the shape below is what you would store.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n let api = $state<SvGridApi<{}, Person> | null>(null)\n let model = $state('(no filters)')\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'department', header: 'Department', width: 160 },\n { field: 'age', header: 'Age', width: 90 },\n ]\n</script>\n\n<button type=\"button\" onclick={() => (model = JSON.stringify(api?.getFilters() ?? {}, null, 2))}>\n Read the filter model\n</button>\n\n<SvGrid data={people} {columns} filterable filterMode=\"row\" onApiReady={(next) => (api = next)} />\n\n<pre>{model}</pre>\n```\n\n## See also\n\n- [Filter conditions](./filter-conditions.md)\n- [Applying filters](./applying-filters.md)\n- [Missing features](../missing-features.md) - `api.getFilters()` would be nice.\n"
5507
+ "markdown": "# Filter API\n\nTwo surfaces - pick based on whether the caller is inside or outside the\ncomponent that owns the grid state.\n<div data-docs-demo=\"64-filter-between-operator\" data-height=\"540\"></div>\n\n## `SvGridApi` (imperative)\n\nAvailable via `<SvGrid onApiReady={(api) => /* … */}>`.\n\n```ts\napi.setFilter(\n columnId: string,\n filter: { operator: SvGridFilterOperator; value?: string } | null,\n): void\n\napi.clearFilter(columnId: string): void\n```\n\n`SvGridFilterOperator` is the union: `'contains' | 'equals' | 'startsWith'\n| 'greaterThan' | 'lessThan' | 'isBlank'`.\n\nPassing `null` clears the filter on that column.\n\n```ts\napi.setFilter('status', { operator: 'equals', value: 'active' })\napi.setFilter('age', { operator: 'greaterThan', value: '30' })\napi.setFilter('email', { operator: 'contains', value: '@example.com' })\napi.setFilter('department', null) // clear\napi.clearFilter('age') // same effect, sugared\n```\n\nThe wrapper internally writes into the `columnFilters` state slice and\nre-runs the filtered row model.\n\nA column with a [`customFilter`](./custom-column-filters.md) holds its own\nmodel rather than an operator and a value:\n\n```ts\napi.setCustomFilter('amount', [1000, 5000]) // whatever its predicate takes\napi.getCustomFilter('amount') // [1000, 5000]\napi.setCustomFilter('amount', null) // clear\n```\n\n## Observe filter state\n\nFor history, persistence, or server-side, subscribe via\n`onFiltersChange`:\n\n```svelte\n<script lang=\"ts\">\n let filters = $state<Array<{\n id: string\n operator: string\n value: string\n selectedValues?: Array<string>\n }>>([])\n let globalFilter = $state('')\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n onFiltersChange={(next) => { filters = next.columns; globalFilter = next.global }}\n/>\n```\n\nThe callback receives a consolidated `{ global, columns }` shape so the\nthree internal stores (global search, per-column operator filter, facet\nchecklist) collapse into one payload you can serialise.\n\nFor server-side / external pipelines, also set `externalFilter={true}`\nso the grid doesn't filter the rows locally - see [Custom column\nfilters](./custom-column-filters.md).\n\n## Read the current filters\n\nFrom the imperative API there is no `getFilters()` getter today - read the\nunderlying state via the grid instance:\n\n```ts\nconst grid = api as unknown as { /* internals are not part of the public type */ }\n```\n\nThis is not a stable surface. If you need to read filters from outside,\ncontrol the state.\n\n## Setting a filter from code\n\n`setFilter(columnId, { operator, value })` applies the same filter the menu\nwould, so a \"show me only Platform\" button and the UI end up in one state rather\nthan two.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n let api = $state<SvGridApi<{}, Person> | null>(null)\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'department', header: 'Department', width: 160 },\n { field: 'salary', header: 'Salary', width: 140,\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<div>\n <button type=\"button\" onclick={() => api?.setFilter('department', { operator: 'equals', value: 'Platform' })}>\n Platform only\n </button>\n <button type=\"button\" onclick={() => api?.setFilter('department', null)}>Clear</button>\n</div>\n\n<SvGrid data={people} {columns} filterable filterMode=\"menu\" onApiReady={(next) => (api = next)} />\n```\n\n\n## Reading what is applied\n\n`getFilters()` returns the live model, which is what you persist to a URL or a\nsaved view. Apply a filter above and the shape below is what you would store.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n let api = $state<SvGridApi<{}, Person> | null>(null)\n let model = $state('(no filters)')\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'department', header: 'Department', width: 160 },\n { field: 'age', header: 'Age', width: 90 },\n ]\n</script>\n\n<button type=\"button\" onclick={() => (model = JSON.stringify(api?.getFilters() ?? {}, null, 2))}>\n Read the filter model\n</button>\n\n<SvGrid data={people} {columns} filterable filterMode=\"row\" onApiReady={(next) => (api = next)} />\n\n<pre>{model}</pre>\n```\n\n## See also\n\n- [Filter conditions](./filter-conditions.md)\n- [Applying filters](./applying-filters.md)\n- [Missing features](../missing-features.md) - `api.getFilters()` would be nice.\n"
5508
5508
  },
5509
5509
  {
5510
5510
  "slug": "help/filtering/filter-conditions",
@@ -5518,7 +5518,7 @@ export const docs = [
5518
5518
  "path": "docs/help/filtering/floating-filters.md",
5519
5519
  "title": "Floating filters",
5520
5520
  "section": "Filtering",
5521
- "markdown": "# Floating filters\n\n\"Floating filters\" are the always-visible filter inputs that sit **between** the\nheader row and the body. In SvGrid the filter row is **per-operator and\nper-type**: each cell has a funnel to pick the operator, the value input matches\nthe column's type, and `between` shows a second inline \"To\" input.\n<div data-docs-demo=\"179-floating-filters\" data-height=\"520\"></div>\n\n## Enable the filter row\n\n```svelte\n<SvGrid {data} {columns} features={features} filterMode=\"row\" />\n```\n\n## Per-operator, per-type\n\n- **Operator per cell** - click the funnel in a filter-row cell to switch the\n operator (`contains`, `equals`, `greaterThan`, `between`, `isBlank`, …). The\n chosen operator drives that column's filtering; it is the same value the column\n menu uses, so the two surfaces stay in sync.\n- **Input matches the column** - a `number` column gets a number input, a `date`\n column a date picker, text a text input. This is driven by the column's\n `editorType`.\n- **`between` inline** - selecting Between shows a second **To** input right in\n the row, so ranges work without opening the full menu. A date column swaps in\n a single **range field** instead: one popover, two-month calendar, both ends\n picked in one gesture. A range needs both ends - the filter stays inactive\n until the second one is picked.\n\n```ts\nconst columns = [\n { field: 'age', header: 'Age', editorType: 'number' }, // number input\n { field: 'joinedAt', header: 'Joined', editorType: 'date' }, // date picker\n { field: 'name', header: 'Name', editorType: 'text' }, // text input\n]\n```\n\nTo show both the filter row and the column-menu funnel, set both surfaces:\n\n```svelte\n<SvGrid\n {data} {columns} features={features}\n showFilterRow={true}\n showColumnFilters={true}\n/>\n```\n\n## The filter row\n\n`showFilterRow` puts a persistent input under every header. It wins over\n`filterMode` when both are set, which is how you get a filter row alongside the\nmenu rather than instead of it.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n joined: string\n active: boolean\n trend: number[]\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000, joined: '2021-03-01', active: true, trend: [4, 6, 5, 9, 12, 11, 15] },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000, joined: '2019-07-15', active: true, trend: [9, 8, 11, 10, 14, 16, 15] },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000, joined: '2020-01-20', active: false, trend: [12, 10, 9, 7, 6, 6, 4] },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000, joined: '2022-09-05', active: true, trend: [3, 5, 4, 8, 7, 11, 13] },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'department', header: 'Department', width: 160 },\n { field: 'age', header: 'Age', width: 90, editorType: 'number' },\n ]\n</script>\n\n<SvGrid data={people} {columns} filterable showFilterRow sortable />\n```\n\n\n## Row and menu together\n\nThe row handles the common case in one keystroke; the menu behind each header\nstill carries the full operator list for the times that is not enough.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n joined: string\n active: boolean\n trend: number[]\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000, joined: '2021-03-01', active: true, trend: [4, 6, 5, 9, 12, 11, 15] },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000, joined: '2019-07-15', active: true, trend: [9, 8, 11, 10, 14, 16, 15] },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000, joined: '2020-01-20', active: false, trend: [12, 10, 9, 7, 6, 6, 4] },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000, joined: '2022-09-05', active: true, trend: [3, 5, 4, 8, 7, 11, 13] },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'city', header: 'City', width: 150 },\n { field: 'salary', header: 'Salary', width: 150, editorType: 'number',\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid data={people} {columns} filterable filterMode=\"menu\" showFilterRow />\n```\n\n## See also\n\n- [Overview](./overview.md)\n- [Filter conditions](./filter-conditions.md) - two conditions per column\n- [Custom header components](../columns/custom-header-components.md)\n"
5521
+ "markdown": "# Floating filters\n\n\"Floating filters\" are the always-visible filter inputs that sit **between** the\nheader row and the body. In SvGrid the filter row is **per-operator and\nper-type**: each cell has a funnel to pick the operator, the value input matches\nthe column's type, and `between` shows a second inline \"To\" input.\n<div data-docs-demo=\"179-floating-filters\" data-height=\"520\"></div>\n\n## Enable the filter row\n\n```svelte\n<SvGrid {data} {columns} features={features} filterMode=\"row\" />\n```\n\n## Per-operator, per-type\n\n- **Operator per cell** - click the funnel in a filter-row cell to switch the\n operator (`contains`, `equals`, `greaterThan`, `between`, `isBlank`, …). The\n chosen operator drives that column's filtering; it is the same value the column\n menu uses, so the two surfaces stay in sync.\n- **Input matches the column** - a `number` column gets a number input, a `date`\n column a date picker, text a text input. This is driven by the column's\n `editorType`.\n- **`between` inline** - selecting Between shows a second **To** input right in\n the row, so ranges work without opening the full menu. A date column swaps in\n a single **range field** instead: one popover, two-month calendar, both ends\n picked in one gesture. A range needs both ends - the filter stays inactive\n until the second one is picked.\n\n```ts\nconst columns = [\n { field: 'age', header: 'Age', editorType: 'number' }, // number input\n { field: 'joinedAt', header: 'Joined', editorType: 'date' }, // date picker\n { field: 'name', header: 'Name', editorType: 'text' }, // text input\n]\n```\n\nTo show both the filter row and the column-menu funnel, set both surfaces:\n\n```svelte\n<SvGrid\n {data} {columns} features={features}\n showFilterRow={true}\n showColumnFilters={true}\n/>\n```\n\n## The filter row\n\n`showFilterRow` puts a persistent input under every header. It wins over\n`filterMode` when both are set, which is how you get a filter row alongside the\nmenu rather than instead of it.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n joined: string\n active: boolean\n trend: number[]\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000, joined: '2021-03-01', active: true, trend: [4, 6, 5, 9, 12, 11, 15] },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000, joined: '2019-07-15', active: true, trend: [9, 8, 11, 10, 14, 16, 15] },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000, joined: '2020-01-20', active: false, trend: [12, 10, 9, 7, 6, 6, 4] },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000, joined: '2022-09-05', active: true, trend: [3, 5, 4, 8, 7, 11, 13] },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'department', header: 'Department', width: 160 },\n { field: 'age', header: 'Age', width: 90, editorType: 'number' },\n ]\n</script>\n\n<SvGrid data={people} {columns} filterable showFilterRow sortable />\n```\n\n\n## Row and menu together\n\nThe row handles the common case in one keystroke; the menu behind each header\nstill carries the full operator list for the times that is not enough.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n joined: string\n active: boolean\n trend: number[]\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000, joined: '2021-03-01', active: true, trend: [4, 6, 5, 9, 12, 11, 15] },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000, joined: '2019-07-15', active: true, trend: [9, 8, 11, 10, 14, 16, 15] },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000, joined: '2020-01-20', active: false, trend: [12, 10, 9, 7, 6, 6, 4] },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000, joined: '2022-09-05', active: true, trend: [3, 5, 4, 8, 7, 11, 13] },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'city', header: 'City', width: 150 },\n { field: 'salary', header: 'Salary', width: 150, editorType: 'number',\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid data={people} {columns} filterable filterMode=\"menu\" showFilterRow />\n```\n\n## Your own control in the row\n\nA column's `customFilter.floating` snippet takes the place of the operator\nbutton and input in that column's cell; the other columns keep theirs. See\n[Custom column filters](./custom-column-filters.md).\n\n## See also\n\n- [Overview](./overview.md)\n- [Filter conditions](./filter-conditions.md) - two conditions per column\n- [Custom header components](../columns/custom-header-components.md)\n"
5522
5522
  },
5523
5523
  {
5524
5524
  "slug": "help/filtering/number-filter",
@@ -5665,14 +5665,14 @@ export const docs = [
5665
5665
  "path": "docs/help/mcp-server.md",
5666
5666
  "title": "MCP server",
5667
5667
  "section": "Help",
5668
- "markdown": "# MCP server\n\nThe SvGrid MCP server does two things for an AI client (Claude Code,\nClaude Desktop, Cursor, Zed, Codex, custom agents): it answers questions\nabout SvGrid from the files this repository ships, and it **checks the\ncode the model writes** against the real exported surface of the version\nyou have installed. No API key either way.\n\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)\n\n> **What is MCP?** Model Context Protocol is the open standard\n> ([modelcontextprotocol.io](https://modelcontextprotocol.io)) for\n> exposing tools to LLM clients. SvGrid ships an MCP server so the\n> model your team already uses can \"see\" the grid without you having\n> to copy-paste docs into prompts.\n\nIt runs either way you like: as a local process\n([`@svgrid/mcp`](https://www.npmjs.com/package/@svgrid/mcp) on npm), or as\na hosted endpoint at `https://mcp.svgrid.com/mcp` that needs no install at\nall. Both are listed in the official MCP registry as `com.svgrid/svgrid`.\n\n## Two ways to connect\n\n**Hosted (nothing to install).** Point any MCP client at the URL:\n\n```\nhttps://mcp.svgrid.com/mcp\n```\n\n```bash\nclaude mcp add --transport http svgrid https://mcp.svgrid.com/mcp\n```\n\nOne click:\n[Add to Cursor](https://cursor.com/en/install-mcp?name=svgrid&config=eyJ1cmwiOiJodHRwczovL21jcC5zdmdyaWQuY29tL21jcCJ9)\n· [Add to VS Code](https://insiders.vscode.dev/redirect/mcp/install?name=svgrid&config=%7B%22name%22%3A%22svgrid%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fmcp.svgrid.com%2Fmcp%22%7D)\n\nIt carries seven tools - `search`, `fetch`, `list_examples`,\n`get_example_source`, `get_api_reference`, `check_svgrid_code` and\n`svgrid_preview` - and needs no Node, no config file, and no key.\n\n**Local (`npx @svgrid/mcp`).** Everything the hosted server has, plus\nthe Studio tools, the docs and demos as MCP resources, ready-made\nprompts, and a `svgrid_check_code` that additionally *compiles* the file\nwith the Svelte compiler rather than checking it statically. Use it when\nyou want the compile pass, the Studio tools, or no third-party endpoint\nin the loop.\n\n| | Hosted | Local |\n| --- | --- | --- |\n| Setup | a URL | `npx @svgrid/mcp` |\n| Needs Node | no | yes |\n| Code check compiles | no, static checks only | yes |\n| Resources and prompts | yes | yes |\n| Visual preview | yes | yes |\n| `studio_*` tools | no | yes, opt-in |\n| Works offline | no | yes |\n\n## Install\n\nNo install step is required - `npx` fetches it on demand:\n\n```bash\n# One-shot, from any project\nnpx -y @svgrid/mcp\n```\n\nTo pin it as a dev dependency instead:\n\n```bash\npnpm add -D @svgrid/mcp\n```\n\nThe local server is a Node binary (`svgrid-mcp`) that speaks MCP over\nstdio. There is no daemon to maintain. The hosted server needs no install\nstep at all - skip to [Wire it into your AI client](#wire-it-into-your-ai-client)\nand use the URL.\n\n## Wire it into your AI client\n\nEvery snippet below shows the local (stdio) form and the hosted (HTTP)\nform. Pick one - running both connects the same tools twice.\n\n### Claude Code\n\nThe plugin is the shortest path: it installs the\n[Agent Skill](./skill.md) and the hosted server together, so the model\ngets the house style *and* the checker in one step.\n\n```\n/plugin marketplace add sv-grid/sv-grid\n/plugin install svgrid@svgrid\n```\n\nOr add the server on its own:\n\n```bash\n# hosted, nothing to install\nclaude mcp add --transport http svgrid https://mcp.svgrid.com/mcp\n\n# or local\nclaude mcp add svgrid -- npx -y @svgrid/mcp\n```\n\nThen run `/mcp` in a session and you will see `svgrid` listed.\n\nTo share the server with your team, add `--scope project`. That writes\na `.mcp.json` at the repository root which you can commit, so everyone\nwho clones the repo gets the same tooling with no per-machine setup:\n\n```bash\nclaude mcp add svgrid --scope project -- npx -y @svgrid/mcp\n```\n\n### Claude Desktop\n\nEdit `~/Library/Application Support/Claude/claude_desktop_config.json`\n(macOS) or `%APPDATA%\\Claude\\claude_desktop_config.json` (Windows):\n\n```json\n{\n \"mcpServers\": {\n \"svgrid\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@svgrid/mcp\"]\n }\n }\n}\n```\n\nOr the hosted server, with nothing to install:\n\n```json\n{\n \"mcpServers\": {\n \"svgrid\": {\n \"type\": \"http\",\n \"url\": \"https://mcp.svgrid.com/mcp\"\n }\n }\n}\n```\n\nRestart Claude Desktop, then ask *\"using svgrid, build me a grid that\ngroups by department\"* to confirm the tools are exposed.\n\n### Cursor\n\nOne click:\n[Add to Cursor](https://cursor.com/en/install-mcp?name=svgrid&config=eyJ1cmwiOiJodHRwczovL21jcC5zdmdyaWQuY29tL21jcCJ9)\n\nOr by hand, in `Settings -> MCP -> Add new MCP server`:\n\n```json\n{ \"url\": \"https://mcp.svgrid.com/mcp\" }\n```\n\n```json\n{ \"command\": \"npx\", \"args\": [\"-y\", \"@svgrid/mcp\"] }\n```\n\n### Zed\n\n`~/.config/zed/settings.json`:\n\n```json\n{\n \"context_servers\": {\n \"svgrid\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@svgrid/mcp\"],\n \"env\": {}\n }\n }\n}\n```\n\n### VS Code\n\nOne click:\n[Add to VS Code](https://insiders.vscode.dev/redirect/mcp/install?name=svgrid&config=%7B%22name%22%3A%22svgrid%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fmcp.svgrid.com%2Fmcp%22%7D)\n\nOr create `.vscode/mcp.json` in the workspace. Note that VS Code uses\n`servers` rather than the `mcpServers` wrapper:\n\n```json\n{\n \"servers\": {\n \"svgrid\": {\n \"type\": \"http\",\n \"url\": \"https://mcp.svgrid.com/mcp\"\n }\n }\n}\n```\n\n```json\n{\n \"servers\": {\n \"svgrid\": {\n \"type\": \"stdio\",\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@svgrid/mcp\"]\n }\n }\n}\n```\n\n### Custom agents (OpenAI Agents SDK, Anthropic SDK, LangChain)\n\nPoint your client's MCP stdio transport at `npx -y @svgrid/mcp`, or its\nstreamable-HTTP transport at `https://mcp.svgrid.com/mcp`. Any client that\nspeaks either transport works.\n\nThe hosted server names its two retrieval tools `search` and `fetch`\nexactly, which is what a connector needs to index it.\n\n## Tools exposed\n\nFive, by default. `tools/list` is sent on **every** request, so the tool\nsurface is pure overhead on every turn - and this server used to spend\n~4,710 tokens of it on 36 tools, 79% of them Studio tools that most\nsessions never call once.\n\n| Tool | What it does |\n| ---- | ------------ |\n| `svgrid_search` | Search the docs, every demo and the API surface **in one call**. No arguments returns an index. |\n| `svgrid_get` | Read one thing in full: a doc slug, a demo id, or `api`. |\n| `svgrid_check_code` | Verify code against the real exported surface before the user sees it. |\n| `svgrid_preview` | Render a **live, interactive grid** in the conversation from columns + rows. |\n| `svgrid_scaffold` | Turn a Drizzle schema, sample rows or an EntitySchema into runnable SvelteKit files. |\n\nStudio adds four more, and only when you ask for them - set\n`SVGRID_MCP_STUDIO=1` or a licence key in the server's env. They need a\nlicence to be useful, so they are not charged to everyone else's context\nwindow.\n\nThe **hosted** server carries `search`, `fetch`, `list_examples`,\n`get_example_source`, `get_api_reference`, `check_svgrid_code` and\n`svgrid_preview`, plus the same resources and prompts. The first two are named\nthat way deliberately: those exact names are what a one-click connector needs to\nindex a remote server. It also answers to `svgrid_search`, `svgrid_get` and\n`svgrid_check_code`, so a model that learned the npm package's names is never\ntold \"unknown tool\".\n\nWhat it does **not** have is the compile pass (the Svelte compiler cannot run in\na Worker), the Studio tools (they need a filesystem), and the offline guarantee\n- it is a remote server, so by definition your query reaches it. Use the local\none when any of those matter.\n\n### Verification\n\n#### `svgrid_check_code`\n\nChecks a file **against the version you have installed** and returns\nline-numbered diagnostics with the exact replacement for each: unknown\n`<SvGrid>` props, wrong `ColumnDef` keys, api methods that do not\nexist, imports that will not resolve, and Svelte 5 runes mistakes. It\nalso compiles the component, so a syntax error surfaces here rather\nthan in the user's terminal.\n\nThis is the tool that makes the rest worth having. Retrieval alone\nstill lets a model write a confident, wrong grid; nothing else in the\nserver stops it. Run it on every file you write, fix what it reports,\nand run it again.\n\n### Finding and reading\n\n#### `svgrid_search`\n\nOne call across all three corpora, because \"how do I pin a column\" does\nnot announce whether it is answered by a doc page, a demo, or an API\nname:\n\n```json\n{ \"query\": \"pin a column\" }\n```\n\nReturns ranked doc hits with excerpts, matching demos, and matching API\nnames - each with the reference you pass to `svgrid_get`. Narrow with\n`kind` (`docs` | `examples` | `api`), trade tokens for completeness\nwith `detail` (`concise` | `full`), and cap with `limit` (max 50).\n\nCall it with **no arguments** for an index of doc sections, demo\ncategories and API groups - the cheapest way to orient before searching.\n\n#### `svgrid_get`\n\n```json\n{ \"ref\": \"help/columns/column-definitions\" }\n{ \"ref\": \"11-stock-market\" }\n{ \"ref\": \"api\" }\n```\n\nThe kind is inferred from the reference; pass `kind` to force it. A\nreference that does not resolve comes back with near matches rather\nthan a bare \"not found\".\n\n### It fixes what it finds\n\n`svgrid_check_code` does not stop at telling you what is wrong. Where the\ncorrection is exact it returns the corrected file too:\n\n```json\n{\n \"diagnostics\": [ ... ],\n \"applied\": [\"rowData -> data (line 7)\", \"accessorKey -> field (line 3)\"],\n \"fixed\": \"<the corrected source>\"\n}\n```\n\nBoth fields are omitted when there is nothing mechanically fixable, so a clean\ncheck stays small.\n\nThe rules are deliberately narrow, because this tool's whole worth is that it\nnever cries wolf - and rewriting raises the stakes from wasting a turn to\ncorrupting working code. Only exact renames are applied (a known rename, or a\nclose-enough spelling), only on word boundaries, and only on the line the\ndiagnostic reported. Advice that is ambiguous - \"this has no equivalent, remove\nit\" - stays advice. A test asserts that running auto-fix across every demo\nin this repository changes nothing at all.\n\n### It knows which version you have\n\nThe result carries the version it checked against:\n\n```json\n{ \"version\": { \"corpus\": \"3.0.0\", \"installed\": \"2.6.8\", \"warning\": \"...\" } }\n```\n\nThe server reads the `@svgrid/grid` in your project and says plainly when it\ndisagrees with the corpus it ships, so a model is told to hedge instead of\nconfidently citing an API you do not have. A server that answers from a remote\nbackend cannot do this - it has no idea what is in your `node_modules`.\n\n### Nothing leaves your machine\n\nThis server makes **no network calls**. Not for docs, not for the API surface,\nnot for verification - the whole corpus (every demo, every doc page, the full\nexported API) ships inside the package, which is why it is megabytes rather\nthan kilobytes. There is no API key, no licence check on the wire, and no telemetry.\n\nIt works on a plane, behind a corporate proxy, and inside an air-gapped\nnetwork. `tools/mcp-offline.test.ts` enforces it: the server is driven through\na full session with every network primitive booby-trapped, and the test fails if\nanything is even attempted.\n\nThe one honest exception: the **preview** loads the grid from a CDN to draw it.\nThat happens in your client's sandboxed iframe, never in this server, and only\nwhen you actually render a preview.\n\n### Seeing the grid, not just reading about it\n\n`svgrid_preview` renders a **real, interactive SvGrid inside the\nconversation** - sortable, filterable, scrollable. Not a screenshot and not a\nmock table: the same `<sv-grid>` custom element a page would use, loaded from\nthe CDN, with the columns and rows you passed.\n\n```json\n{\n \"title\": \"Team roster\",\n \"columns\": [{ \"field\": \"name\", \"header\": \"Name\" }, { \"field\": \"amount\", \"header\": \"Amount\" }],\n \"data\": [{ \"name\": \"Ada\", \"amount\": 20000 }],\n \"sortable\": true\n}\n```\n\nIt uses [MCP Apps](https://blog.modelcontextprotocol.io/posts/2026-01-26-mcp-apps/),\nthe official UI extension: the tool points at a `ui://` resource through\n`_meta`, the client loads that HTML in a sandboxed iframe, and the tool's\n`structuredContent` arrives over a postMessage bridge. Supported in Claude\n(web and desktop), VS Code and ChatGPT.\n\nClients without UI support are not left out - they ignore the `_meta` and get\na text summary of the same grid, so it is always safe to call. That is the\nextension's own rule, and it is why the text half describes the grid rather\nthan pointing at a picture the reader cannot see.\n\nPass `demo` instead of `columns` / `data` and you get the demo's source: a\ndemo is a Svelte component, not data, so there is nothing to hand the element\nand rendering an empty grid would just look broken.\n\n### Resources and prompts\n\nThe docs and demos are also exposed as **MCP resources**, so a user can\nattach a page or a demo directly instead of hoping the model thinks to\nsearch for it:\n\n```\nsvgrid://doc/help/columns/column-definitions\nsvgrid://example/11-stock-market\n```\n\nAnd three **prompts** ship ready to run: `build_grid`, `explain_api`\nand `review_grid_code`. Each is written to make the model use this\nserver rather than recall SvGrid from training data, and each ends at\n`svgrid_check_code`.\n\n### SvGrid Studio (commercial)\n\nOff unless enabled. Set `SVGRID_MCP_STUDIO=1`, or a valid\n`SVGRID_LICENSE_KEY`, in the server's env.\n\n#### `svgrid_scaffold`\n\nPoint it at a Drizzle schema, sample JSON rows, or an `EntitySchema`\nyou already have, and it infers the schema and generates the `$lib`\nschema module, a `+server.ts` API route and a `+page.svelte` with\n`SvGrid` + `SvGridEditPanel` - in one call. Pass `schemaOnly: true` to\nstop after inference. Generated bodies carry `svgrid:managed` markers,\nso regenerating preserves your edits outside them.\n\n#### The `studio_*` tools\n\nFour tools drive the same validated project model the visual designer\nuses:\n\n| Tool | What it does |\n| ---- | ------------ |\n| `studio_project` | `new`, `load`, `describe`, `config`, `capabilities` |\n| `studio_apply` | A **batch** of model changes - entities, screens, blocks, components - applied in order |\n| `studio_configure` | Theme, auth, access, tenancy, data layer, jobs, deploy target, layouts, in one call |\n| `studio_build` | `validate`, then `generate` the full runnable SvelteKit app |\n\nCall `studio_project` with `action: \"capabilities\"` first: it reports\nexactly what the installed version supports, so the agent does not have\nto guess block kinds or component keys.\n\n`studio_apply` takes a batch on purpose. Building a five-screen app was\ntwenty-odd round trips when every block was its own tool; now a whole\nscreen is one call, and a failure reports which op failed and what\nalready applied.\n\n### The pre-3.0 names\n\nEvery tool name from before 3.0 still answers - `search_docs`,\n`get_doc`, `list_examples`, `get_example_source`, `list_docs`,\n`get_api_reference`, `check_svgrid_code`, `introspect_source`,\n`scaffold_entity` and all 27 individual `studio_*` tools. They are not\n**listed**, because listing is what costs context and answering a name\nyou did not advertise costs nothing. Saved prompts and scripts keep\nworking; nobody pays for the compatibility.\n\nTheir JSON response shape follows 3.0, not 2.x. A model reads either\nwithout trouble; a script that parsed the old shape by hand needs a look.\n\n## Licensing\n\nThe documentation and example tools are free. The Studio code\ngenerators are part of the commercial offering. You can evaluate them\nwithout a key: they run, but prepend a notice comment\nto generated files. To license them, set\nthe key in your MCP client's server config:\n\n```json\n{\n \"mcpServers\": {\n \"svgrid\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@svgrid/mcp\"],\n \"env\": { \"SVGRID_LICENSE_KEY\": \"YOUR-LICENSE-KEY\" }\n }\n }\n}\n```\n\n## Enterprise features and the trial\n\nMost of SvGrid is the MIT `@svgrid/grid` package. Export to Excel and PDF,\npivot tables, the Kanban board, the scheduler, the spreadsheet and the\nserver-side row model are in `@svgrid/enterprise`, which is commercial.\n\nThe server tells your assistant which is which. In `svgrid_search` results a\npaid doc or demo carries `\"enterprise\": true`, and when a question is about a\npaid feature the answer says so once, with the trial link.\n`svgrid_get` puts the same line at the top of a paid doc or demo, and\n`svgrid_check_code` adds it when the fix is to install the package. Nothing\nis sent anywhere to do this; it is part of the answer text.\n\n`@svgrid/enterprise` runs without a license key and shows a watermark until\none is set, so you can try a feature before deciding. The\n[30-day trial](https://svgrid.com/pricing/?trial=mcp-docs) adds a download of\nevery demo and 30 days of email support.\n\n## Verifying it works\n\nAsk your model: *\"What MCP tools do you have from svgrid?\"* The local\nserver lists five, including `svgrid_check_code` (four Studio tools more when\nenabled); the hosted server lists seven.\n\nThen give it something to catch:\n\n> Check this with svgrid: `<SvGrid rowData={rows} {columns} />`\n\nIt should come back with `rowData` is not a SvGrid prop, use `data`. If\nit explains the code instead of calling a tool, the server is not\nconnected.\n\nIf nothing is listed at all, check your client's MCP log. For the local\nserver the usual cause is `npx` not being on PATH - use the absolute path\nto the binary. For the hosted one, confirm the endpoint answers:\n\n```bash\ncurl https://mcp.svgrid.com/health\n```\n\n## Security model\n\nThe two ways of running it differ here, so pick deliberately.\n\n**Local (`npx @svgrid/mcp`)**\n\n- Runs on your machine over stdio. No telemetry, no outbound network\n calls, no API key.\n- Serves a documentation and example corpus bundled into the package at\n build time, so answers are pinned to the version you installed.\n- `svgrid_check_code` reads the source you pass it in-process and never\n sends it anywhere.\n\n**Hosted (`https://mcp.svgrid.com/mcp`)**\n\n- Your client sends tool arguments to a Cloudflare Worker we operate.\n Anything you pass to a tool leaves your machine, and for\n `check_svgrid_code` that means **the source you ask it to check**.\n- Each call is recorded: tool name, duration, whether it succeeded, the\n query or id it was given, how many pages matched, and whether the answer\n mentioned the Enterprise trial. For `check_svgrid_code` only the **byte\n length** of the submitted source and the names of the rules that fired\n are recorded, never the source itself. The records stay in Cloudflare\n (Workers logs, and an Analytics Engine dataset kept three months) and are\n read in aggregate to see what people ask for.\n- No account, no key, no cookies. Requests are not tied to a user.\n- If your code cannot leave the building, use the local server. That is\n what it is for.\n\n**Both**\n\n- The Studio tools return generated files as data. Writing them to disk\n is your client's decision, not the server's.\n- See [security](./security.md) for the general supply-chain posture.\n\n## Building your own MCP integrations\n\nThe same docs manifest, JSON Schemas (column definitions, the grid's\noptions, export options, and the chart spec at `schemas/chart-spec.json`,\nwhich `svgrid_get` also serves for \"chart spec schema\"), and `llms.txt`\nfiles are also served directly from the docs site:\n\n```ts\nconst docs = await fetch('https://svgrid.com/docs.json').then((r) => r.json())\nconst schemas = await fetch('https://svgrid.com/schemas/index.json').then((r) => r.json())\nconst llms = await fetch('https://svgrid.com/llms-full.txt').then((r) => r.text())\n```\n\nIf you do not want to run the MCP server, building these into your\nagent's system prompt gives most of the same grounding.\n\n## Frequently asked questions\n\n### What is the SvGrid MCP server?\n\nA Model Context Protocol server that lets AI clients (Claude Code, Claude\nDesktop, Cursor, Zed, custom agents) query SvGrid's documentation, read real\ndemo source, scaffold SvelteKit CRUD apps, and check the code they write\nagainst the real API - grounded in the files the package ships, so the model\nanswers from current facts instead of guessing.\n\n### Do I need an API key to run it?\n\nNo, on either transport. A `SVGRID_LICENSE_KEY` is optional and only affects\nthe commercial Studio code generators.\n\n### Local or hosted - which should I use?\n\nHosted (`https://mcp.svgrid.com/mcp`) if you want it working in one paste,\nwith no Node and no config file. Local (`npx @svgrid/mcp`) if you want the\nSvelte compiler pass in `svgrid_check_code`, the Studio tools, offline\nuse, or your source never leaving the machine. See\n[Security model](#security-model) for what each one sends.\n\n### How does it help AI assistants write better SvGrid code?\n\nTwo ways. It exposes the docs, example sources, and API reference as tools,\nso the assistant retrieves version-pinned answers instead of hallucinating an\nAPI from training data. And `svgrid_check_code` closes the loop: the model\nruns what it wrote past the real exported surface and gets told exactly what\nis wrong before you see it. That matters most for Svelte 5, where models\nroutinely mix in outdated Svelte 4 syntax.\n\n### Does it work with a version of SvGrid I have not upgraded to?\n\nThe local server answers for the version bundled in the `@svgrid/mcp` release\nyou install, so pin it alongside `@svgrid/grid` if you are behind. The hosted\nserver always tracks the current release. Either way, every\n`svgrid_check_code` result names the version it checked against in its\n`checkedAgainst` field.\n\n## See also\n\n- [Agent Skill](./skill.md) - the always-on house-style layer; the Claude Code plugin installs it together with this server\n- [LLM grounding](./llm-grounding.md) - the same files used by the MCP server, but documented for direct LLM consumption\n- [Agents](./agents.md) - how to build an AI agent that drives the live grid\n- [AI assistant](./ai.md) - the in-grid AI features (filter / smart-fill / classify / summarise), free in @svgrid/grid\n"
5668
+ "markdown": "# MCP server\n\nThe SvGrid MCP server does two things for an AI client (Claude Code,\nClaude Desktop, Cursor, Zed, Codex, custom agents): it answers questions\nabout SvGrid from the files this repository ships, and it **checks the\ncode the model writes** against the real exported surface of the version\nyou have installed. No API key either way.\n\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)\n\n> **What is MCP?** Model Context Protocol is the open standard\n> ([modelcontextprotocol.io](https://modelcontextprotocol.io)) for\n> exposing tools to LLM clients. SvGrid ships an MCP server so the\n> model your team already uses can \"see\" the grid without you having\n> to copy-paste docs into prompts.\n\nIt runs either way you like: as a local process\n([`@svgrid/mcp`](https://www.npmjs.com/package/@svgrid/mcp) on npm), or as\na hosted endpoint at `https://mcp.svgrid.com/mcp` that needs no install at\nall. Both are listed in the official MCP registry as `com.svgrid/svgrid`.\n\n## Two ways to connect\n\n**Hosted (nothing to install).** Point any MCP client at the URL:\n\n```\nhttps://mcp.svgrid.com/mcp\n```\n\n```bash\nclaude mcp add --transport http svgrid https://mcp.svgrid.com/mcp\n```\n\nOne click:\n[Add to Cursor](https://cursor.com/en/install-mcp?name=svgrid&config=eyJ1cmwiOiJodHRwczovL21jcC5zdmdyaWQuY29tL21jcCJ9)\n· [Add to VS Code](https://insiders.vscode.dev/redirect/mcp/install?name=svgrid&config=%7B%22name%22%3A%22svgrid%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fmcp.svgrid.com%2Fmcp%22%7D)\n\nIt carries seven tools - `search`, `fetch`, `list_examples`,\n`get_example_source`, `get_api_reference`, `check_svgrid_code` and\n`svgrid_preview` - and needs no Node, no config file, and no key.\n\n**Local (`npx @svgrid/mcp`).** Everything the hosted server has, plus\nthe Studio tools, the docs and demos as MCP resources, ready-made\nprompts, and a `svgrid_check_code` that additionally *compiles* the file\nwith the Svelte compiler rather than checking it statically. Use it when\nyou want the compile pass, the Studio tools, or no third-party endpoint\nin the loop.\n\n| | Hosted | Local |\n| --- | --- | --- |\n| Setup | a URL | `npx @svgrid/mcp` |\n| Needs Node | no | yes |\n| Code check compiles | no, static checks only | yes |\n| Resources and prompts | yes | yes |\n| Visual preview | yes | yes |\n| `studio_*` tools | no | yes, opt-in |\n| Works offline | no | yes |\n\n## Install\n\nNo install step is required - `npx` fetches it on demand:\n\n```bash\n# One-shot, from any project\nnpx -y @svgrid/mcp\n```\n\nTo pin it as a dev dependency instead:\n\n```bash\npnpm add -D @svgrid/mcp\n```\n\nThe local server is a Node binary (`svgrid-mcp`) that speaks MCP over\nstdio. There is no daemon to maintain. The hosted server needs no install\nstep at all - skip to [Wire it into your AI client](#wire-it-into-your-ai-client)\nand use the URL.\n\n## Wire it into your AI client\n\nEvery snippet below shows the local (stdio) form and the hosted (HTTP)\nform. Pick one - running both connects the same tools twice.\n\n### Claude Code\n\nThe plugin is the shortest path: it installs the\n[Agent Skill](./skill.md) and the hosted server together, so the model\ngets the house style *and* the checker in one step.\n\n```\n/plugin marketplace add sv-grid/sv-grid\n/plugin install svgrid@svgrid\n```\n\nOr add the server on its own:\n\n```bash\n# hosted, nothing to install\nclaude mcp add --transport http svgrid https://mcp.svgrid.com/mcp\n\n# or local\nclaude mcp add svgrid -- npx -y @svgrid/mcp\n```\n\nThen run `/mcp` in a session and you will see `svgrid` listed.\n\nTo share the server with your team, add `--scope project`. That writes\na `.mcp.json` at the repository root which you can commit, so everyone\nwho clones the repo gets the same tooling with no per-machine setup:\n\n```bash\nclaude mcp add svgrid --scope project -- npx -y @svgrid/mcp\n```\n\n### Claude Desktop\n\nEdit `~/Library/Application Support/Claude/claude_desktop_config.json`\n(macOS) or `%APPDATA%\\Claude\\claude_desktop_config.json` (Windows):\n\n```json\n{\n \"mcpServers\": {\n \"svgrid\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@svgrid/mcp\"]\n }\n }\n}\n```\n\nOr the hosted server, with nothing to install:\n\n```json\n{\n \"mcpServers\": {\n \"svgrid\": {\n \"type\": \"http\",\n \"url\": \"https://mcp.svgrid.com/mcp\"\n }\n }\n}\n```\n\nRestart Claude Desktop, then ask *\"using svgrid, build me a grid that\ngroups by department\"* to confirm the tools are exposed.\n\n### Cursor\n\nOne click:\n[Add to Cursor](https://cursor.com/en/install-mcp?name=svgrid&config=eyJ1cmwiOiJodHRwczovL21jcC5zdmdyaWQuY29tL21jcCJ9)\n\nOr by hand, in `Settings -> MCP -> Add new MCP server`:\n\n```json\n{ \"url\": \"https://mcp.svgrid.com/mcp\" }\n```\n\n```json\n{ \"command\": \"npx\", \"args\": [\"-y\", \"@svgrid/mcp\"] }\n```\n\n### Zed\n\n`~/.config/zed/settings.json`:\n\n```json\n{\n \"context_servers\": {\n \"svgrid\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@svgrid/mcp\"],\n \"env\": {}\n }\n }\n}\n```\n\n### VS Code\n\nOne click:\n[Add to VS Code](https://insiders.vscode.dev/redirect/mcp/install?name=svgrid&config=%7B%22name%22%3A%22svgrid%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fmcp.svgrid.com%2Fmcp%22%7D)\n\nOr create `.vscode/mcp.json` in the workspace. Note that VS Code uses\n`servers` rather than the `mcpServers` wrapper:\n\n```json\n{\n \"servers\": {\n \"svgrid\": {\n \"type\": \"http\",\n \"url\": \"https://mcp.svgrid.com/mcp\"\n }\n }\n}\n```\n\n```json\n{\n \"servers\": {\n \"svgrid\": {\n \"type\": \"stdio\",\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@svgrid/mcp\"]\n }\n }\n}\n```\n\n### Custom agents (OpenAI Agents SDK, Anthropic SDK, LangChain)\n\nPoint your client's MCP stdio transport at `npx -y @svgrid/mcp`, or its\nstreamable-HTTP transport at `https://mcp.svgrid.com/mcp`. Any client that\nspeaks either transport works.\n\nThe hosted server names its two retrieval tools `search` and `fetch`\nexactly, which is what a connector needs to index it.\n\n## Tools exposed\n\nFive, by default. `tools/list` is sent on **every** request, so the tool\nsurface is pure overhead on every turn - and this server used to spend\n~4,710 tokens of it on 36 tools, 79% of them Studio tools that most\nsessions never call once.\n\n| Tool | What it does |\n| ---- | ------------ |\n| `svgrid_search` | Search the docs, every demo and the API surface **in one call**. No arguments returns an index. |\n| `svgrid_get` | Read one thing in full: a doc slug, a demo id, or `api`. |\n| `svgrid_check_code` | Verify code against the real exported surface before the user sees it. |\n| `svgrid_preview` | Render a **live, interactive grid** in the conversation from columns + rows. |\n| `svgrid_scaffold` | Turn a Drizzle schema, sample rows or an EntitySchema into runnable SvelteKit files. |\n\nStudio adds four more, and only when you ask for them - set\n`SVGRID_MCP_STUDIO=1` or a licence key in the server's env. They need a\nlicence to be useful, so they are not charged to everyone else's context\nwindow.\n\n`svgrid_scaffold` and the Studio tools run on `@svgrid/enterprise`, which the\nserver loads only when it is installed beside it. `npx -y @svgrid/mcp` leaves\nit out so the server starts fast. To scaffold, install both in the project and\nrun the local server:\n\n```bash\nnpm i -D @svgrid/mcp @svgrid/enterprise\nclaude mcp add svgrid -- npx svgrid-mcp\n```\n\nWithout `-y`, `npx` runs the installed copy instead of downloading one, so\nthere is no install on the first connect.\n\nThe **hosted** server carries `search`, `fetch`, `list_examples`,\n`get_example_source`, `get_api_reference`, `check_svgrid_code` and\n`svgrid_preview`, plus the same resources and prompts. The first two are named\nthat way deliberately: those exact names are what a one-click connector needs to\nindex a remote server. It also answers to `svgrid_search`, `svgrid_get` and\n`svgrid_check_code`, so a model that learned the npm package's names is never\ntold \"unknown tool\".\n\nWhat it does **not** have is the compile pass (the Svelte compiler cannot run in\na Worker), the Studio tools (they need a filesystem), and the offline guarantee\n- it is a remote server, so by definition your query reaches it. Use the local\none when any of those matter.\n\n### Verification\n\n#### `svgrid_check_code`\n\nChecks a file **against the version you have installed** and returns\nline-numbered diagnostics with the exact replacement for each: unknown\n`<SvGrid>` props, wrong `ColumnDef` keys, api methods that do not\nexist, imports that will not resolve, and Svelte 5 runes mistakes. It\nalso compiles the component, so a syntax error surfaces here rather\nthan in the user's terminal.\n\nThis is the tool that makes the rest worth having. Retrieval alone\nstill lets a model write a confident, wrong grid; nothing else in the\nserver stops it. Run it on every file you write, fix what it reports,\nand run it again.\n\n### Finding and reading\n\n#### `svgrid_search`\n\nOne call across all three corpora, because \"how do I pin a column\" does\nnot announce whether it is answered by a doc page, a demo, or an API\nname:\n\n```json\n{ \"query\": \"pin a column\" }\n```\n\nReturns ranked doc hits with excerpts, matching demos, and matching API\nnames - each with the reference you pass to `svgrid_get`. Narrow with\n`kind` (`docs` | `examples` | `api`), trade tokens for completeness\nwith `detail` (`concise` | `full`), and cap with `limit` (max 50).\n\nCall it with **no arguments** for an index of doc sections, demo\ncategories and API groups - the cheapest way to orient before searching.\n\n#### `svgrid_get`\n\n```json\n{ \"ref\": \"help/columns/column-definitions\" }\n{ \"ref\": \"11-stock-market\" }\n{ \"ref\": \"api\" }\n```\n\nThe kind is inferred from the reference; pass `kind` to force it. A\nreference that does not resolve comes back with near matches rather\nthan a bare \"not found\".\n\n### It fixes what it finds\n\n`svgrid_check_code` does not stop at telling you what is wrong. Where the\ncorrection is exact it returns the corrected file too:\n\n```json\n{\n \"diagnostics\": [ ... ],\n \"applied\": [\"rowData -> data (line 7)\", \"accessorKey -> field (line 3)\"],\n \"fixed\": \"<the corrected source>\"\n}\n```\n\nBoth fields are omitted when there is nothing mechanically fixable, so a clean\ncheck stays small.\n\nThe rules are deliberately narrow, because this tool's whole worth is that it\nnever cries wolf - and rewriting raises the stakes from wasting a turn to\ncorrupting working code. Only exact renames are applied (a known rename, or a\nclose-enough spelling), only on word boundaries, and only on the line the\ndiagnostic reported. Advice that is ambiguous - \"this has no equivalent, remove\nit\" - stays advice. A test asserts that running auto-fix across every demo\nin this repository changes nothing at all.\n\n### It knows which version you have\n\nThe result carries the version it checked against:\n\n```json\n{ \"version\": { \"corpus\": \"3.0.0\", \"installed\": \"2.6.8\", \"warning\": \"...\" } }\n```\n\nThe server reads the `@svgrid/grid` in your project and says plainly when it\ndisagrees with the corpus it ships, so a model is told to hedge instead of\nconfidently citing an API you do not have. A server that answers from a remote\nbackend cannot do this - it has no idea what is in your `node_modules`.\n\n### Nothing leaves your machine\n\nThis server makes **no network calls**. Not for docs, not for the API surface,\nnot for verification - the whole corpus (every demo, every doc page, the full\nexported API) ships inside the package, which is why it is megabytes rather\nthan kilobytes. There is no API key, no licence check on the wire, and no telemetry.\n\nIt works on a plane, behind a corporate proxy, and inside an air-gapped\nnetwork. `tools/mcp-offline.test.ts` enforces it: the server is driven through\na full session with every network primitive booby-trapped, and the test fails if\nanything is even attempted.\n\nThe one honest exception: the **preview** loads the grid from a CDN to draw it.\nThat happens in your client's sandboxed iframe, never in this server, and only\nwhen you actually render a preview.\n\n### Seeing the grid, not just reading about it\n\n`svgrid_preview` renders a **real, interactive SvGrid inside the\nconversation** - sortable, filterable, scrollable. Not a screenshot and not a\nmock table: the same `<sv-grid>` custom element a page would use, loaded from\nthe CDN, with the columns and rows you passed.\n\n```json\n{\n \"title\": \"Team roster\",\n \"columns\": [{ \"field\": \"name\", \"header\": \"Name\" }, { \"field\": \"amount\", \"header\": \"Amount\" }],\n \"data\": [{ \"name\": \"Ada\", \"amount\": 20000 }],\n \"sortable\": true\n}\n```\n\nIt uses [MCP Apps](https://blog.modelcontextprotocol.io/posts/2026-01-26-mcp-apps/),\nthe official UI extension: the tool points at a `ui://` resource through\n`_meta`, the client loads that HTML in a sandboxed iframe, and the tool's\n`structuredContent` arrives over a postMessage bridge. Supported in Claude\n(web and desktop), VS Code and ChatGPT.\n\nClients without UI support are not left out - they ignore the `_meta` and get\na text summary of the same grid, so it is always safe to call. That is the\nextension's own rule, and it is why the text half describes the grid rather\nthan pointing at a picture the reader cannot see.\n\nPass `demo` instead of `columns` / `data` and you get the demo's source: a\ndemo is a Svelte component, not data, so there is nothing to hand the element\nand rendering an empty grid would just look broken.\n\n### Resources and prompts\n\nThe docs and demos are also exposed as **MCP resources**, so a user can\nattach a page or a demo directly instead of hoping the model thinks to\nsearch for it:\n\n```\nsvgrid://doc/help/columns/column-definitions\nsvgrid://example/11-stock-market\n```\n\nAnd three **prompts** ship ready to run: `build_grid`, `explain_api`\nand `review_grid_code`. Each is written to make the model use this\nserver rather than recall SvGrid from training data, and each ends at\n`svgrid_check_code`.\n\n### SvGrid Studio (commercial)\n\nOff unless enabled. Set `SVGRID_MCP_STUDIO=1`, or a valid\n`SVGRID_LICENSE_KEY`, in the server's env, and run the server from a project\nthat has `@svgrid/enterprise` installed beside it\n(`npm i -D @svgrid/mcp @svgrid/enterprise`, then `npx svgrid-mcp`). Without it\nthese tools answer with those steps instead of running.\n\n#### `svgrid_scaffold`\n\nPoint it at a Drizzle schema, sample JSON rows, or an `EntitySchema`\nyou already have, and it infers the schema and generates the `$lib`\nschema module, a `+server.ts` API route and a `+page.svelte` with\n`SvGrid` + `SvGridEditPanel` - in one call. Pass `schemaOnly: true` to\nstop after inference. Generated bodies carry `svgrid:managed` markers,\nso regenerating preserves your edits outside them.\n\n#### The `studio_*` tools\n\nFour tools drive the same validated project model the visual designer\nuses:\n\n| Tool | What it does |\n| ---- | ------------ |\n| `studio_project` | `new`, `load`, `describe`, `config`, `capabilities` |\n| `studio_apply` | A **batch** of model changes - entities, screens, blocks, components - applied in order |\n| `studio_configure` | Theme, auth, access, tenancy, data layer, jobs, deploy target, layouts, in one call |\n| `studio_build` | `validate`, then `generate` the full runnable SvelteKit app |\n\nCall `studio_project` with `action: \"capabilities\"` first: it reports\nexactly what the installed version supports, so the agent does not have\nto guess block kinds or component keys.\n\n`studio_apply` takes a batch on purpose. Building a five-screen app was\ntwenty-odd round trips when every block was its own tool; now a whole\nscreen is one call, and a failure reports which op failed and what\nalready applied.\n\n### The pre-3.0 names\n\nEvery tool name from before 3.0 still answers - `search_docs`,\n`get_doc`, `list_examples`, `get_example_source`, `list_docs`,\n`get_api_reference`, `check_svgrid_code`, `introspect_source`,\n`scaffold_entity` and all 27 individual `studio_*` tools. They are not\n**listed**, because listing is what costs context and answering a name\nyou did not advertise costs nothing. Saved prompts and scripts keep\nworking; nobody pays for the compatibility.\n\nTheir JSON response shape follows 3.0, not 2.x. A model reads either\nwithout trouble; a script that parsed the old shape by hand needs a look.\n\n## Licensing\n\nThe documentation and example tools are free. The Studio code\ngenerators are part of the commercial offering. You can evaluate them\nwithout a key: they run, but prepend a notice comment\nto generated files. To license them, set\nthe key in your MCP client's server config:\n\n```json\n{\n \"mcpServers\": {\n \"svgrid\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@svgrid/mcp\"],\n \"env\": { \"SVGRID_LICENSE_KEY\": \"YOUR-LICENSE-KEY\" }\n }\n }\n}\n```\n\n## Enterprise features and the trial\n\nMost of SvGrid is the MIT `@svgrid/grid` package. Export to Excel and PDF,\npivot tables, the Kanban board, the scheduler, the spreadsheet and the\nserver-side row model are in `@svgrid/enterprise`, which is commercial.\n\nThe server tells your assistant which is which. In `svgrid_search` results a\npaid doc or demo carries `\"enterprise\": true`, and when a question is about a\npaid feature the answer says so once, with the trial link.\n`svgrid_get` puts the same line at the top of a paid doc or demo, and\n`svgrid_check_code` adds it when the fix is to install the package. Nothing\nis sent anywhere to do this; it is part of the answer text.\n\n`@svgrid/enterprise` runs without a license key and shows a watermark until\none is set, so you can try a feature before deciding. The\n[30-day trial](https://svgrid.com/pricing/?trial=mcp-docs) adds a download of\nevery demo and 30 days of email support.\n\n## Verifying it works\n\nAsk your model: *\"What MCP tools do you have from svgrid?\"* The local\nserver lists five, including `svgrid_check_code` (four Studio tools more when\nenabled); the hosted server lists seven.\n\nThen give it something to catch:\n\n> Check this with svgrid: `<SvGrid rowData={rows} {columns} />`\n\nIt should come back with `rowData` is not a SvGrid prop, use `data`. If\nit explains the code instead of calling a tool, the server is not\nconnected.\n\nIf nothing is listed at all, check your client's MCP log. For the local\nserver the usual cause is `npx` not being on PATH - use the absolute path\nto the binary. For the hosted one, confirm the endpoint answers:\n\n```bash\ncurl https://mcp.svgrid.com/health\n```\n\n## Security model\n\nThe two ways of running it differ here, so pick deliberately.\n\n**Local (`npx @svgrid/mcp`)**\n\n- Runs on your machine over stdio. No telemetry, no outbound network\n calls, no API key.\n- Serves a documentation and example corpus bundled into the package at\n build time, so answers are pinned to the version you installed.\n- `svgrid_check_code` reads the source you pass it in-process and never\n sends it anywhere.\n\n**Hosted (`https://mcp.svgrid.com/mcp`)**\n\n- Your client sends tool arguments to a Cloudflare Worker we operate.\n Anything you pass to a tool leaves your machine, and for\n `check_svgrid_code` that means **the source you ask it to check**.\n- Each call is recorded: tool name, duration, whether it succeeded, the\n query or id it was given, how many pages matched, and whether the answer\n mentioned the Enterprise trial. For `check_svgrid_code` only the **byte\n length** of the submitted source and the names of the rules that fired\n are recorded, never the source itself. The records stay in Cloudflare\n (Workers logs, and an Analytics Engine dataset kept three months) and are\n read in aggregate to see what people ask for.\n- No account, no key, no cookies. Requests are not tied to a user.\n- If your code cannot leave the building, use the local server. That is\n what it is for.\n\n**Both**\n\n- The Studio tools return generated files as data. Writing them to disk\n is your client's decision, not the server's.\n- See [security](./security.md) for the general supply-chain posture.\n\n## Building your own MCP integrations\n\nThe same docs manifest, JSON Schemas (column definitions, the grid's\noptions, export options, and the chart spec at `schemas/chart-spec.json`,\nwhich `svgrid_get` also serves for \"chart spec schema\"), and `llms.txt`\nfiles are also served directly from the docs site:\n\n```ts\nconst docs = await fetch('https://svgrid.com/docs.json').then((r) => r.json())\nconst schemas = await fetch('https://svgrid.com/schemas/index.json').then((r) => r.json())\nconst llms = await fetch('https://svgrid.com/llms-full.txt').then((r) => r.text())\n```\n\nIf you do not want to run the MCP server, building these into your\nagent's system prompt gives most of the same grounding.\n\n## Frequently asked questions\n\n### What is the SvGrid MCP server?\n\nA Model Context Protocol server that lets AI clients (Claude Code, Claude\nDesktop, Cursor, Zed, custom agents) query SvGrid's documentation, read real\ndemo source, scaffold SvelteKit CRUD apps, and check the code they write\nagainst the real API - grounded in the files the package ships, so the model\nanswers from current facts instead of guessing.\n\n### Do I need an API key to run it?\n\nNo, on either transport. A `SVGRID_LICENSE_KEY` is optional and only affects\nthe commercial Studio code generators.\n\n### Local or hosted - which should I use?\n\nHosted (`https://mcp.svgrid.com/mcp`) if you want it working in one paste,\nwith no Node and no config file. Local (`npx @svgrid/mcp`) if you want the\nSvelte compiler pass in `svgrid_check_code`, the Studio tools, offline\nuse, or your source never leaving the machine. See\n[Security model](#security-model) for what each one sends.\n\n### How does it help AI assistants write better SvGrid code?\n\nTwo ways. It exposes the docs, example sources, and API reference as tools,\nso the assistant retrieves version-pinned answers instead of hallucinating an\nAPI from training data. And `svgrid_check_code` closes the loop: the model\nruns what it wrote past the real exported surface and gets told exactly what\nis wrong before you see it. That matters most for Svelte 5, where models\nroutinely mix in outdated Svelte 4 syntax.\n\n### Does it work with a version of SvGrid I have not upgraded to?\n\nThe local server answers for the version bundled in the `@svgrid/mcp` release\nyou install, so pin it alongside `@svgrid/grid` if you are behind. The hosted\nserver always tracks the current release. Either way, every\n`svgrid_check_code` result names the version it checked against in its\n`checkedAgainst` field.\n\n## See also\n\n- [Agent Skill](./skill.md) - the always-on house-style layer; the Claude Code plugin installs it together with this server\n- [LLM grounding](./llm-grounding.md) - the same files used by the MCP server, but documented for direct LLM consumption\n- [Agents](./agents.md) - how to build an AI agent that drives the live grid\n- [AI assistant](./ai.md) - the in-grid AI features (filter / smart-fill / classify / summarise), free in @svgrid/grid\n"
5669
5669
  },
5670
5670
  {
5671
5671
  "slug": "help/migrating-from-ag-grid",
5672
5672
  "path": "docs/help/migrating-from-ag-grid.md",
5673
5673
  "title": "Migrating from AG Grid to SvGrid",
5674
5674
  "section": "Help",
5675
- "markdown": "# Migrating from AG Grid to SvGrid\n\nIf you tried AG Grid on a Svelte 5 project - via `ag-grid-svelte`, the\nold `ag-grid-community/svelte`, or a hand-rolled wrapper - you probably\nhit the same friction everyone hits: the bridge between AG Grid's\nReact/Angular-first API and Svelte 5 runes is brittle, the bundle is\nheavy, and the Enterprise pricing only makes sense at scale.\n\n<!-- facts:start ag-grid -->\n> **Facts, checked 12 Sep 2026.** `ag-grid-community` 36.1.0, MIT, last published 5 Aug 2026, 12,400,000 npm downloads in the 30 days to 10 Sep 2026. `@svgrid/grid` 3.0.3, MIT, last published 11 Sep 2026, 16,900 npm downloads in the same window. Bundle, minified and gzipped, each package built alone with Svelte external: SvGrid 3.0.4 97.6 KB JS + 10.5 KB CSS (measured 22 Sep 2026); `ag-grid-community` 36.1.0 317.5 KB JS, no separate stylesheet (measured 12 Sep 2026). AG Grid pricing, as its site states it: AG Grid Community is free under MIT. AG Grid Enterprise is listed at $999 USD per developer with one year of updates and Zendesk support; the Enterprise Bundle with AG Charts Enterprise is $1,498 USD per developer (https://www.ag-grid.com/license-pricing/, read 12 Sep 2026). SvGrid: MIT core; @svgrid/enterprise from $299 per developer per year (Indie, under $1M revenue), $599 for Enterprise. Side by side, with sources: [SvGrid vs AG Grid (community + enterprise)](https://svgrid.com/compare/ag-grid/).\n<!-- facts:end -->\n\nThis page is a 30-minute migration recipe from AG Grid to SvGrid. It\ncovers what maps 1:1, what's different by design, and what you'll lose.\nWe tell you when **not** to switch at the bottom.\n\n## TL;DR\n\n| | AG Grid Community | AG Grid Enterprise | SvGrid Community | @svgrid/enterprise |\n| --- | --- | --- | --- | --- |\n| **Licence** | MIT | Commercial, per developer (the price is in the facts box above) | **MIT** | $599/dev/yr (grid) or $999/dev/yr (grid + spreadsheet + Studio) |\n| **Svelte 5 native** | No (community-built integration) | No (community-built integration) | Yes | Yes |\n| **Bundle (gzipped)** | measured, in the facts box above | not measured (licence) | measured, in the facts box above | lazy-loaded subpaths |\n| **Sorting / filtering** | Yes | Yes | Yes | (in Community) |\n| **Row grouping + aggregation** | No (Enterprise) | Yes | Yes (free) | (in Community) |\n| **Master/detail, tree, range select** | No (Enterprise) | Yes | Yes (free) | (in Community) |\n| **Set filter / Excel-style filter menu** | No (Enterprise) | Yes | Yes (free) | (in Community) |\n| **Server-side data, flat (infinite row model)** | Yes | Yes | Yes (free) | (in Community) |\n| **Server-side row model (grouping, tree, pivot, transactions, selection)** | No (Enterprise) | Yes | No | Yes |\n| **Integrated charts** | No (Enterprise) | Yes, AG Charts | Yes (free) | (in Community) |\n| **CSV export** | Yes (API) | Yes | Yes, with TSV and JSON | (in Community) |\n| **Excel export** | No | Yes | No | Yes |\n| **PDF / styled-HTML export, print** | No | No | No | Yes |\n| **Pivot tables** | No | Yes | No | Yes |\n| **MCP server for AI assistants** | Yes, `ag-mcp` | Yes | Yes, `@svgrid/mcp` | (in Community) |\n| **In-grid AI helpers** | No | No | Yes (bring your own model) | (in Community) |\n\n**SvGrid Community gives you most of what AG Grid sells as Enterprise for\nfree**, and `@svgrid/enterprise` adds Excel, PDF and print output, import,\npivot tables and the server-side row model per developer per year. Compare what each paid tier adds\nbefore comparing prices; both are in the facts box. The trade-offs are\nSvelte-only and a much smaller ecosystem. Every AG Grid cell above follows\nag-grid.com's own pages; the dated list is on the\n[SvGrid vs AG Grid](https://svgrid.com/compare/ag-grid/) page.\n\n## Mental model - what changes\n\nAG Grid is one big object you configure declaratively. SvGrid is a\n**headless engine** (`createSvGrid`) with an optional **render\ncomponent** (`<SvGrid>`) on top - the same split TanStack Table made\npopular. You can use either layer; most projects use the render\ncomponent.\n\nEvery example below runs against this setup:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, tableFeatures, rowSortingFeature, columnFilteringFeature, type ColumnDef } from '@svgrid/grid'\n\n type Row = { id: number; make: string; model: string; price: number }\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n\n let rows = $state<Row[]>([\n { id: 1, make: 'Toyota', model: 'Celica', price: 35000 },\n { id: 2, make: 'Ford', model: 'Mondeo', price: 32000 },\n { id: 3, make: 'Porsche', model: 'Boxster', price: 72000 },\n ])\n const data = rows\n\n const columns: ColumnDef<typeof features, Row>[] = [\n { field: 'make', header: 'Make' },\n { field: 'model', header: 'Model' },\n { field: 'price', header: 'Price', format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```svelte\n<!-- AG Grid (via a Svelte wrapper) -->\n<AgGridSvelte\n gridOptions={{\n rowData: rows,\n columnDefs: columns,\n onGridReady: (params) => (gridApi = params.api),\n }}\n/>\n\n<!-- SvGrid -->\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n onApiReady={(api) => (gridApi = api)}\n/>\n```\n\nThree things to note:\n\n1. **No giant `gridOptions` blob.** Each capability is a top-level prop.\n2. **Features are opt-in.** You pass a `features` object built with\n `tableFeatures({...})` - only the features you list ship JS.\n3. **`onApiReady`** gives you a typed `SvGridApi` that is roughly the\n AG Grid `gridApi` equivalent (see the API-mapping table below).\n\n## Column definitions - direct translation\n\nThe shapes are similar enough that you can usually translate by hand\nwithout thinking too hard.\n\n```ts\n// AG Grid\nconst columnDefs: ColDef[] = [\n { field: 'name', headerName: 'Name', sortable: true, filter: true, width: 200 },\n { field: 'price', headerName: 'Price', type: 'numericColumn',\n valueFormatter: ({ value }) => `$${value.toFixed(2)}` },\n { field: 'date', headerName: 'Date',\n valueGetter: ({ data }) => new Date(data.date).toISOString().slice(0, 10) },\n { field: 'status', headerName: 'Status',\n cellRenderer: StatusCellRenderer,\n cellRendererParams: { onChange: handleStatusChange } },\n]\n```\n\n```ts\n// SvGrid\nimport { renderComponent, type ColumnDef } from '@svgrid/grid'\nimport StatusCell from './StatusCell.svelte'\n\nconst columns: ColumnDef<typeof features, Row>[] = [\n { field: 'name', header: 'Name', width: 200 }, // sortable + filterable by default\n { field: 'price', header: 'Price',\n format: { type: 'currency', currency: 'USD' } },\n { field: 'date', header: 'Date',\n format: { type: 'date', pattern: 'y-m-d' } },\n { field: 'status', header: 'Status',\n cell: renderComponent(StatusCell, (ctx) => ({\n value: ctx.getValue(),\n onChange: handleStatusChange,\n })),\n },\n]\n```\n\n### Property mapping\n\n| AG Grid | SvGrid | Notes |\n| --- | --- | --- |\n| `field` | `field` | Same. |\n| `headerName` | `header` | Accepts a string or a snippet/component. |\n| `width` | `width` | Same. |\n| `minWidth` / `maxWidth` | `minWidth` / `maxWidth` | Same. |\n| `sortable: true` | (default) | Sorting is on when `rowSortingFeature` is registered. |\n| `filter: true` | (default) | Filtering is on when `columnFilteringFeature` is registered. |\n| `valueFormatter` | `format: { ... }` | Built-in types: `number`, `currency`, `percent`, `date`. For custom, use `cell`. |\n| `valueGetter` | `fieldFn` | Returns the value for sorting/filtering. |\n| `cellRenderer` + `cellRendererParams` | `cell: renderComponent(C, ctx => props)` | One call, type-checked. |\n| `cellEditor: 'agTextCellEditor'` | `editorType: 'text'` | Built-in: `text`, `number`, `checkbox`, `date`. |\n| `editable: true` | `enableInlineEditing` prop on `<SvGrid>` | Per-grid, not per-column. (Per-column control on the roadmap.) |\n| `pinned: 'left'` / `'right'` | Right-click column menu → Pin | Set programmatically via the api. |\n| `rowGroup: true` | Via `setGroupBy([colId])` | See Grouping below. |\n| `aggFunc: 'sum'` | `aggregate: 'sum'` | Built-in: `sum`, `avg`, `min`, `max`, `count`. |\n\n## Feature registration - the one new thing\n\nAG Grid auto-enables most features; you turn them off. SvGrid is the\nopposite - features are opt-in. The result is a smaller bundle.\n\n```ts\nimport {\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n rowPaginationFeature,\n rowSelectionFeature,\n} from '@svgrid/grid'\n\nconst features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n rowSelectionFeature,\n // omit any you don't need - their code won't ship\n})\n```\n\nPass `features` to `<SvGrid>` once. From then on the grid behaves like\nAG Grid's `enableSorting`, `enableFilter`, `rowSelection`, etc. are all\non for the registered features.\n\n## API mapping (`gridApi` → `SvGridApi`)\n\nYou get the SvGrid API from `onApiReady` (equivalent to AG Grid's\n`onGridReady`).\n\n| AG Grid `gridApi.X()` | SvGrid `api.X()` |\n| --- | --- |\n| `setRowData(rows)` | (declarative - just update `data` prop) |\n| `addRow(row)` / `applyTransaction({ add: [row] })` | `api.addRow(row)` / `api.addRows(rows)` |\n| `applyTransaction({ remove: [row] })` | `api.removeRow(rowIndex)` |\n| `getValue(colId, rowNode)` | `api.getCellValue(rowIndex, columnId)` |\n| `setValue(...)` | `api.setCellValue(rowIndex, columnId, value)` |\n| `setColumnVisible(colId, visible)` | `api.setColumnVisible(columnId, visible)` |\n| `getSortModel()` / `setSortModel()` | `api.setSort(columnId, 'asc'\\|'desc'\\|null)` |\n| `setFilterModel({...})` | `api.setFilter(columnId, { operator, value })` |\n| `getDisplayedRowAtIndex(i)` / `forEachNodeAfterFilterAndSort(...)` | `api.getDisplayedRows()` |\n| `getModel()` (raw rows) | `api.getData()` |\n\n## Common patterns\n\n### Sorting + filtering + pagination (the 80% case)\n\n```svelte\n<script lang=\"ts\">\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n rowPaginationFeature,\n } from '@svgrid/grid'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n rowPaginationFeature,\n })\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n showPagination\n showColumnFilters\n/>\n```\n\nAG Grid equivalent: `gridOptions: { defaultColDef: { sortable: true, filter: true }, pagination: true }`.\n\n### Cell editing with persistence\n\n```svelte\n<script lang=\"ts\">\n function onCellValueChange(e: { rowIndex: number; columnId: string; value: unknown }) {\n // Persist however you like (fetch to backend, optimistic local update, etc.)\n console.log('cell changed', e)\n }\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n enableInlineEditing\n onCellValueChange={onCellValueChange}\n/>\n```\n\nAG Grid equivalent: `onCellValueChanged: ({ data, colDef, newValue, oldValue }) => ...`.\nSvGrid's event payload is column-id + row-index based rather than node-based; the row data is yours\nto mutate (or not) on the `rows` array you passed in.\n\n### Grouping + aggregation\n\n```svelte\n<script lang=\"ts\">\n import {\n SvGrid,\n tableFeatures,\n columnGroupingFeature,\n rowSortingFeature,\n rowExpandingFeature,\n } from '@svgrid/grid'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n })\n\n const columns = [\n { field: 'department', header: 'Department' },\n { field: 'team', header: 'Team' },\n { field: 'salary', header: 'Salary', aggregate: 'sum',\n format: { type: 'currency', currency: 'USD' } },\n ]\n\n function setGroup(api) {\n api.setGroupBy(['department', 'team'])\n }\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n showGroupingControls\n onApiReady={setGroup}\n/>\n```\n\nAG Grid Enterprise's `rowGroupPanelShow: 'always'` + `aggFunc: 'sum'` translates 1:1.\n\n### Master / detail\n\nAG Grid Enterprise feature; **free in SvGrid Community**. See\n[demo 08](https://svgrid.com/demos/08-tree-and-master-detail/) for the exact pattern.\n\n### Server-side data\n\nAG Grid's Infinite Row Model (Community) and Server-Side Row Model\n(Enterprise) both take an `IServerSideDatasource` with a `getRows(params)`\nthat answers through `params.success({ rowData, rowCount })`. SvGrid has one\n`ServerDataSource` whose `getRows(request)` returns `{ rows, rowCount }`,\nand two controllers over it: `createServerDataSource` (free: paging or\ninfinite block scroll, sort, filter, CRUD) and `createServerRowModel`\n(Enterprise: lazy grouping, tree, pivot, transactions, selection). Both\nmount through one prop, `<SvGrid rowModel={ctl} />`.\n\nAn existing datasource written for AG Grid keeps working behind one call:\n\n```ts\nimport { adaptCallbackDatasource, createServerRowModel } from '@svgrid/enterprise'\n\nconst ctl = createServerRowModel(adaptCallbackDatasource(myServerSideDatasource), {\n groupBy: ['country'],\n aggregations: [{ col: 'amount', fn: 'sum' }],\n})\n```\n\n`adaptCallbackDatasource` maps the request both ways (`rowGroupCols` ->\n`groupBy`, `valueCols` -> `aggregations`, `pivotCols` -> `pivotBy`, the\nsort and filter models, `context`, `parentNode`) and turns `success` /\n`fail` into a resolved or rejected promise.\n\nMore often the datasource is a thin `fetch` and the real investment is the\nendpoint behind it - a Java or Node service that parses AG Grid's\n`IServerSideGetRowsRequest` JSON. That endpoint stays as it is: post\n`toCallbackRequest(request)` and read the answer back.\n\n```ts\nimport { toCallbackRequest } from '@svgrid/enterprise'\n\nconst source = {\n async getRows(request) {\n const res = await fetch('/api/sales', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(toCallbackRequest(request)),\n })\n const { rowData, rowCount, pivotResultFields } = await res.json()\n return { rows: rowData, rowCount: rowCount ?? -1, pivotResultFields }\n },\n}\n```\n\n`fromCallbackRequest` is the other direction, for a SvelteKit endpoint that\nhas to keep serving an AG Grid client during the switch. A saved selection\nor a bulk endpoint written against `getServerSideSelectionState()` keeps\nits shape the same way: `toCallbackSelectionState(ctl.getSelectionState())`\nproduces `{ selectAll, toggledNodes }` (or the `nodeId` /\n`selectAllChildren` / `toggledNodes` tree), `ctl.setSelectionState` takes\nthat shape as it is when your group node ids are the group keys, and\n`fromCallbackSelectionState(state, { groupKey, isGroup })` maps them when\na `getRowId` gave group rows ids of their own. Option by option:\n\n| AG Grid | SvGrid |\n| --- | --- |\n| `rowModelType: 'infinite'` + `datasource` | `createServerDataSource(source, { mode: 'infinite' })` + `rowModel={ctl}` |\n| `rowModelType: 'serverSide'` + `serverSideDatasource` | `createServerRowModel(source, options)` + `rowModel={ctl}` |\n| `getRows(params)` + `params.success({ rowData, rowCount })` / `params.fail()` | `getRows(request)` returning `{ rows, rowCount }` / throwing |\n| `cacheBlockSize`, `maxBlocksInCache`, `maxConcurrentDatasourceRequests`, `blockLoadDebounceMillis` | `blockSize`, `maxBlocksInCache`, `maxConcurrentRequests`, `blockLoadDebounceMs` |\n| `infiniteInitialRowCount` / `serverSideInitialRowCount` | `initialRowCount` |\n| `rowCount: -1` (unknown) | `rowCount: -1` |\n| `refreshInfiniteCache()` / `purgeInfiniteCache()` | `ctl.refresh()` / `ctl.purge()` |\n| `request.rowGroupCols`, `groupKeys`, `valueCols`, `pivotCols`, `pivotMode` | `request.groupBy`, `groupKeys`, `aggregations`, `pivotBy`, `pivotMode` |\n| `getChildCount(data)` | `childCount: (row) => row.childCount` |\n| `isServerSideGroupOpenByDefault` | `isGroupOpenByDefault(route, row)` |\n| `getServerSideGroupLevelParams` | `levelParams(level, route)` |\n| `isServerSideGroup` / `getServerSideGroupKey` (tree) | `treeData: true` + `hasChildren` / `getRowId` |\n| `grandTotalRow` / `groupTotalRow` | `grandTotalRow` / `groupFooters` |\n| `refreshServerSide({ route, purge })` / `retryServerSideLoads()` | `ctl.refresh({ route, purge })` / `ctl.retryLoads()` |\n| `applyServerSideTransaction` / `applyServerSideTransactionAsync` / `flushServerSideAsyncTransactions` | `ctl.applyTransaction` / `applyTransactionAsync` / `flushAsyncTransactions` |\n| `applyServerSideRowData` | `ctl.applyRowData` |\n| `serverSideSortAllLevels`, `serverSideEnableClientSideSort`, `serverSideOnlyRefreshFilteredGroups` | `sortAllLevels`, `clientSideSort`, `onlyRefreshFilteredGroups` |\n| `getServerSideSelectionState` / `setServerSideSelectionState` | `ctl.getSelectionState()` / `setSelectionState()` (the `toggledNodes` shape is accepted; `toCallbackSelectionState` / `fromCallbackSelectionState` convert) |\n| `ServerSideTransactionResult.status`: `Applied`, `StoreNotFound`, `StoreLoading`, `StoreWaitingToLoad`, `StoreLoadingFailed`, `Cancelled` | `ServerTransactionResult.status`: the same six, lower camel case (`applied`, `storeNotFound`, ...) - a `switch` on the old spelling falls through silently |\n| `groupSelects: 'descendants'` | `selection: { groupSelects: 'descendants' }` |\n| `pivotResultFields` in `success`, `setPivotResultColumns`, `processPivotResultColDef` | `pivotResultFields` / `pivotResultColumns` in the result, `pivotResultColumn(field, def)` |\n| `pagination` + `paginateChildRows` | `pagination: { pageSize, paginateChildRows }` |\n| `getCacheBlockState()` / `getServerSideGroupLevelState()` | `ctl.getCacheState()` / `ctl.levelStates()` |\n| `serverSideDatasource.destroy()` | `source.destroy()` |\n\nTwo differences worth knowing before you start: SvGrid sends the global\nsearch box to the server as `filterModel.global` (AG Grid's quick filter is\nclient-side only and unsupported under its infinite and server-side models),\nand SvGrid has no viewport row model. See\n[Server grouping](./server/server-grouping.md) and\n[demo 467](https://svgrid.com/demos/467-server-row-model-1m/).\n\n### Excel / PDF export\n\nAG Grid: `gridApi.exportDataAsExcel({...})` (Enterprise-only).\nSvGrid: install `@svgrid/enterprise`, call `api.exportData({ format: 'xlsx', ... })`. See [Data export and printing](./export.md).\n\n```ts\nimport { installEnterprise, setLicenseKey } from '@svgrid/enterprise'\nsetLicenseKey('YOUR-LICENSE-KEY') // your Enterprise key\n\n// inside onApiReady:\nconst pro = installEnterprise(api)\nawait pro.exportData({ format: 'xlsx', filename: 'orders' })\n```\n\n## Gotchas - things that don't translate directly\n\n### 1. Per-column `editable: true`\nEditing is switched on at the grid level (`enableInlineEditing`) and\nnarrowed per column with `editable: boolean | (ctx) => boolean` on the\ncolumn definition, so AG Grid's per-column flag and its callback form both\nmap directly. What does not map is AG Grid's `editable` on a column\ngroup: set it on each child column.\n\n### 2. Column drag-to-reorder\nHeader drag is `enableColumnReorder` on `<SvGrid>`; `setColumnOrder`\non the API does the same from code. See\n[column moving](./columns/column-moving.md) for the events.\n\n### 3. AG Grid `valueGetter` chains\nAG Grid's `valueGetter` can read other column values via the API. In\nSvGrid, `fieldFn` only receives the row; if you need cross-column\ncomputed values, do it in the cell renderer with `ctx.row.original` or\ncompute the derived value upstream and store it in the row.\n\n### 4. `cellClass` / `rowClass` callbacks\nOn the roadmap. For now, render a wrapper element in your `cell` snippet\nwith the conditional class.\n\n### 5. The Status Bar / Side Bar / Tool Panels\nAG Grid's chrome (status bar with row count, side bar with filters and\ncolumns panels) doesn't exist in SvGrid - build it as plain Svelte\nmarkup around the grid. Most teams build their own anyway because\nAG Grid's defaults rarely match a polished design system.\n\n### 6. Set filter (the Excel-style funnel popup)\nSvGrid ships an Excel-style filter menu (free in Community). API surface\nis similar but not identical - see [Set filter](./filtering/set-filter.md).\n\n## When NOT to migrate\n\nBe honest. Stay on AG Grid if you:\n\n- **Use multiple frameworks** - AG Grid has React, Angular, Vue, Solid, Qwik, vanilla adapters. SvGrid is Svelte-only.\n- **Need a push-based viewport row model** - SvGrid's server-side row model covers grouping, tree, pivot, transactions and select-all across unloaded rows, and its real-time page merges deltas into loaded rows, but the server cannot push the visible window over a socket the way the viewport model does.\n- **Need pluggable custom filter components or custom tool panels** - SvGrid's tool panel is a fixed Columns + Filters pair.\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.\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.)\n\nIf none of those apply: switching drops a per-developer Enterprise licence\nfor features that are in SvGrid's MIT core, cuts the bundle by the\ndifference in the facts box above, and gives you a Svelte-native API that\nplays well with runes.\n\n## Step-by-step migration\n\nA typical migration of a single grid takes 1-3 hours:\n\n1. **Install** - `pnpm add @svgrid/grid` (and `@svgrid/enterprise` if you need export).\n2. **Translate columnDefs** - use the mapping table above. Most columns are 1:1.\n3. **Wrap features** - figure out which AG Grid features you actually use; register only those in `tableFeatures({...})`.\n4. **Swap the component** - `<AgGridSvelte gridOptions={...}>` → `<SvGrid data={rows} columns={columns} features={features}>`.\n5. **Move event handlers** - AG Grid `onCellValueChanged` → SvGrid `onCellValueChange` (signature differs slightly, see above).\n6. **Move API calls** - AG Grid `gridApi.X()` → SvGrid `api.X()` per the API table.\n7. **Test interactions** - sort, filter, edit, select. Most \"just works.\"\n8. **Remove `ag-grid-*` packages** - `pnpm remove ag-grid-community ag-grid-svelte` etc. Inspect your bundle to confirm the drop; the facts box above has both measured sizes.\n\n## Need help migrating?\n\nEnterprise customers get **migration help included** with the support plan\n(architecture review, port one grid for you as a reference). Email\n`support@jqwidgets.com` after purchase, or `sales@jqwidgets.com` for\npre-sales questions.\n\n## Frequently asked questions\n\n### Is SvGrid a drop-in replacement for AG Grid in Svelte?\n\nNot a literal drop-in - there is no `ag-grid-svelte` shim to swap. But the\nconcepts map closely: column definitions, row models, sorting, filtering,\ngrouping, and an imperative API all have direct SvGrid equivalents, so most\nteams port a grid in 30 minutes to a day. It is a configuration translation,\nnot a rewrite.\n\n### What is the SvGrid equivalent of AG Grid Enterprise?\n\n`@svgrid/enterprise`. It adds Excel/PDF/styled-HTML export (CSV, TSV and JSON export are already free in the grid), a printable view, pivot\ntables and data import. The in-grid AI helpers are free in `@svgrid/grid`.\nIt is licensed per developer ($599 for the grid, $999 with the spreadsheet\nand Studio, both covering unlimited apps), and the\nCommunity package is MIT-licensed and free for commercial use.\n\n### Does SvGrid use Svelte 5 runes, or is it a wrapper?\n\nIt is Svelte-5-native. State is `$state` / `$derived` / `$effect` and cells\nrender through Svelte snippets - there is no React or Angular core underneath\nand no framework bridge to keep in sync.\n\n### Will my AG Grid bundle size shrink?\n\nAlmost always. The facts box at the top of this page has both packages\nmeasured the same way on the same date: the full SvGrid render component\nwith its stylesheet against `createGrid` with every AG Grid Community\nmodule registered. You add `@svgrid/enterprise` features only where you\nimport them, and the headless core on its own is a small fraction of the\nrender component.\n\n## More examples\n\n### AG Grid ↔ sv-grid side-by-side\n\nTwo real grids over the same dataset: the installed AG Grid Community on the left, sv-grid on the right. Same global filter drives both. Source code panels for either side.\n\n<div data-docs-demo=\"139-migration-from-ag-grid\" data-height=\"460\"></div>\n\n## See also\n\n- [Getting started](../getting-started.md) - full SvGrid walkthrough\n- [Why headless?](../why-headless.md) - the headless / render-component split\n- [Data export and printing](./export.md) - the `@svgrid/enterprise` feature pack\n- [Comparison: SvGrid vs AG Grid vs TanStack Table](./comparison.md) - the\n measured benchmark and the feature matrix\n- [SvGrid vs AG Grid comparison page](https://svgrid.com/compare/ag-grid/) -\n every claim with its source and date\n"
5675
+ "markdown": "# Migrating from AG Grid to SvGrid\n\nIf you tried AG Grid on a Svelte 5 project - via `ag-grid-svelte`, the\nold `ag-grid-community/svelte`, or a hand-rolled wrapper - you probably\nhit the same friction everyone hits: the bridge between AG Grid's\nReact/Angular-first API and Svelte 5 runes is brittle, the bundle is\nheavy, and the Enterprise pricing only makes sense at scale.\n\n<!-- facts:start ag-grid -->\n> **Facts, checked 12 Sep 2026.** `ag-grid-community` 36.1.0, MIT, last published 5 Aug 2026, 12,400,000 npm downloads in the 30 days to 10 Sep 2026. `@svgrid/grid` 3.0.3, MIT, last published 11 Sep 2026, 16,900 npm downloads in the same window. Bundle, minified and gzipped, each package built alone with Svelte external: SvGrid 3.0.4 97.6 KB JS + 10.5 KB CSS (measured 22 Sep 2026); `ag-grid-community` 36.1.0 317.5 KB JS, no separate stylesheet (measured 12 Sep 2026). AG Grid pricing, as its site states it: AG Grid Community is free under MIT. AG Grid Enterprise is listed at $999 USD per developer with one year of updates and Zendesk support; the Enterprise Bundle with AG Charts Enterprise is $1,498 USD per developer (https://www.ag-grid.com/license-pricing/, read 12 Sep 2026). SvGrid: MIT core; @svgrid/enterprise from $299 per developer per year (Indie, under $1M revenue), $599 for Enterprise. Side by side, with sources: [SvGrid vs AG Grid (community + enterprise)](https://svgrid.com/compare/ag-grid/).\n<!-- facts:end -->\n\nThis page is a 30-minute migration recipe from AG Grid to SvGrid. It\ncovers what maps 1:1, what's different by design, and what you'll lose.\nWe tell you when **not** to switch at the bottom.\n\n## TL;DR\n\n| | AG Grid Community | AG Grid Enterprise | SvGrid Community | @svgrid/enterprise |\n| --- | --- | --- | --- | --- |\n| **Licence** | MIT | Commercial, per developer (the price is in the facts box above) | **MIT** | $599/dev/yr (grid) or $999/dev/yr (grid + spreadsheet + Studio) |\n| **Svelte 5 native** | No (community-built integration) | No (community-built integration) | Yes | Yes |\n| **Bundle (gzipped)** | measured, in the facts box above | not measured (licence) | measured, in the facts box above | lazy-loaded subpaths |\n| **Sorting / filtering** | Yes | Yes | Yes | (in Community) |\n| **Row grouping + aggregation** | No (Enterprise) | Yes | Yes (free) | (in Community) |\n| **Master/detail, tree, range select** | No (Enterprise) | Yes | Yes (free) | (in Community) |\n| **Set filter / Excel-style filter menu** | No (Enterprise) | Yes | Yes (free) | (in Community) |\n| **Server-side data, flat (infinite row model)** | Yes | Yes | Yes (free) | (in Community) |\n| **Server-side row model (grouping, tree, pivot, transactions, selection)** | No (Enterprise) | Yes | No | Yes |\n| **Integrated charts** | No (Enterprise) | Yes, AG Charts | Yes (free) | (in Community) |\n| **CSV export** | Yes (API) | Yes | Yes, with TSV and JSON | (in Community) |\n| **Excel export** | No | Yes | No | Yes |\n| **PDF / styled-HTML export, print** | No | No | No | Yes |\n| **Pivot tables** | No | Yes | No | Yes |\n| **MCP server for AI assistants** | Yes, `ag-mcp` | Yes | Yes, `@svgrid/mcp` | (in Community) |\n| **In-grid AI helpers** | No | No | Yes (bring your own model) | (in Community) |\n\n**SvGrid Community gives you most of what AG Grid sells as Enterprise for\nfree**, and `@svgrid/enterprise` adds Excel, PDF and print output, import,\npivot tables and the server-side row model per developer per year. Compare what each paid tier adds\nbefore comparing prices; both are in the facts box. The trade-offs are\nSvelte-only and a much smaller ecosystem. Every AG Grid cell above follows\nag-grid.com's own pages; the dated list is on the\n[SvGrid vs AG Grid](https://svgrid.com/compare/ag-grid/) page.\n\n## Mental model - what changes\n\nAG Grid is one big object you configure declaratively. SvGrid is a\n**headless engine** (`createSvGrid`) with an optional **render\ncomponent** (`<SvGrid>`) on top - the same split TanStack Table made\npopular. You can use either layer; most projects use the render\ncomponent.\n\nEvery example below runs against this setup:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, tableFeatures, rowSortingFeature, columnFilteringFeature, type ColumnDef } from '@svgrid/grid'\n\n type Row = { id: number; make: string; model: string; price: number }\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n\n let rows = $state<Row[]>([\n { id: 1, make: 'Toyota', model: 'Celica', price: 35000 },\n { id: 2, make: 'Ford', model: 'Mondeo', price: 32000 },\n { id: 3, make: 'Porsche', model: 'Boxster', price: 72000 },\n ])\n const data = rows\n\n const columns: ColumnDef<typeof features, Row>[] = [\n { field: 'make', header: 'Make' },\n { field: 'model', header: 'Model' },\n { field: 'price', header: 'Price', format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```svelte\n<!-- AG Grid (via a Svelte wrapper) -->\n<AgGridSvelte\n gridOptions={{\n rowData: rows,\n columnDefs: columns,\n onGridReady: (params) => (gridApi = params.api),\n }}\n/>\n\n<!-- SvGrid -->\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n onApiReady={(api) => (gridApi = api)}\n/>\n```\n\nThree things to note:\n\n1. **No giant `gridOptions` blob.** Each capability is a top-level prop.\n2. **Features are opt-in.** You pass a `features` object built with\n `tableFeatures({...})` - only the features you list ship JS.\n3. **`onApiReady`** gives you a typed `SvGridApi` that is roughly the\n AG Grid `gridApi` equivalent (see the API-mapping table below).\n\n## Column definitions - direct translation\n\nThe shapes are similar enough that you can usually translate by hand\nwithout thinking too hard.\n\n```ts\n// AG Grid\nconst columnDefs: ColDef[] = [\n { field: 'name', headerName: 'Name', sortable: true, filter: true, width: 200 },\n { field: 'price', headerName: 'Price', type: 'numericColumn',\n valueFormatter: ({ value }) => `$${value.toFixed(2)}` },\n { field: 'date', headerName: 'Date',\n valueGetter: ({ data }) => new Date(data.date).toISOString().slice(0, 10) },\n { field: 'status', headerName: 'Status',\n cellRenderer: StatusCellRenderer,\n cellRendererParams: { onChange: handleStatusChange } },\n]\n```\n\n```ts\n// SvGrid\nimport { renderComponent, type ColumnDef } from '@svgrid/grid'\nimport StatusCell from './StatusCell.svelte'\n\nconst columns: ColumnDef<typeof features, Row>[] = [\n { field: 'name', header: 'Name', width: 200 }, // sortable + filterable by default\n { field: 'price', header: 'Price',\n format: { type: 'currency', currency: 'USD' } },\n { field: 'date', header: 'Date',\n format: { type: 'date', pattern: 'y-m-d' } },\n { field: 'status', header: 'Status',\n cell: renderComponent(StatusCell, (ctx) => ({\n value: ctx.getValue(),\n onChange: handleStatusChange,\n })),\n },\n]\n```\n\n### Property mapping\n\n| AG Grid | SvGrid | Notes |\n| --- | --- | --- |\n| `field` | `field` | Same. |\n| `headerName` | `header` | Accepts a string or a snippet/component. |\n| `width` | `width` | Same. |\n| `minWidth` / `maxWidth` | `minWidth` / `maxWidth` | Same. |\n| `sortable: true` | (default) | Sorting is on when `rowSortingFeature` is registered. |\n| `filter: true` | (default) | Filtering is on when `columnFilteringFeature` is registered. |\n| `valueFormatter` | `format: { ... }` | Built-in types: `number`, `currency`, `percent`, `date`. For custom, use `cell`. |\n| `valueGetter` | `fieldFn` | Returns the value for sorting/filtering. |\n| `cellRenderer` + `cellRendererParams` | `cell: renderComponent(C, ctx => props)` | One call, type-checked. |\n| `cellEditor: 'agTextCellEditor'` | `editorType: 'text'` | Built-in: `text`, `number`, `checkbox`, `date`. |\n| `editable: true` | `enableInlineEditing` prop on `<SvGrid>` | Per-grid, not per-column. (Per-column control on the roadmap.) |\n| `pinned: 'left'` / `'right'` | Right-click column menu → Pin | Set programmatically via the api. |\n| `rowGroup: true` | Via `setGroupBy([colId])` | See Grouping below. |\n| `aggFunc: 'sum'` | `aggregate: 'sum'` | Built-in: `sum`, `avg`, `min`, `max`, `count`. |\n\n## Feature registration - the one new thing\n\nAG Grid auto-enables most features; you turn them off. SvGrid is the\nopposite - features are opt-in. The result is a smaller bundle.\n\n```ts\nimport {\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n rowPaginationFeature,\n rowSelectionFeature,\n} from '@svgrid/grid'\n\nconst features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n rowSelectionFeature,\n // omit any you don't need - their code won't ship\n})\n```\n\nPass `features` to `<SvGrid>` once. From then on the grid behaves like\nAG Grid's `enableSorting`, `enableFilter`, `rowSelection`, etc. are all\non for the registered features.\n\n## API mapping (`gridApi` → `SvGridApi`)\n\nYou get the SvGrid API from `onApiReady` (equivalent to AG Grid's\n`onGridReady`).\n\n| AG Grid `gridApi.X()` | SvGrid `api.X()` |\n| --- | --- |\n| `setRowData(rows)` | (declarative - just update `data` prop) |\n| `addRow(row)` / `applyTransaction({ add: [row] })` | `api.addRow(row)` / `api.addRows(rows)` |\n| `applyTransaction({ remove: [row] })` | `api.removeRow(rowIndex)` |\n| `getValue(colId, rowNode)` | `api.getCellValue(rowIndex, columnId)` |\n| `setValue(...)` | `api.setCellValue(rowIndex, columnId, value)` |\n| `setColumnVisible(colId, visible)` | `api.setColumnVisible(columnId, visible)` |\n| `getSortModel()` / `setSortModel()` | `api.setSort(columnId, 'asc'\\|'desc'\\|null)` |\n| `setFilterModel({...})` | `api.setFilter(columnId, { operator, value })` |\n| `getDisplayedRowAtIndex(i)` / `forEachNodeAfterFilterAndSort(...)` | `api.getDisplayedRows()` |\n| `getModel()` (raw rows) | `api.getData()` |\n\n## Common patterns\n\n### Sorting + filtering + pagination (the 80% case)\n\n```svelte\n<script lang=\"ts\">\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n rowPaginationFeature,\n } from '@svgrid/grid'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n rowPaginationFeature,\n })\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n showPagination\n showColumnFilters\n/>\n```\n\nAG Grid equivalent: `gridOptions: { defaultColDef: { sortable: true, filter: true }, pagination: true }`.\n\n### Cell editing with persistence\n\n```svelte\n<script lang=\"ts\">\n function onCellValueChange(e: { rowIndex: number; columnId: string; value: unknown }) {\n // Persist however you like (fetch to backend, optimistic local update, etc.)\n console.log('cell changed', e)\n }\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n enableInlineEditing\n onCellValueChange={onCellValueChange}\n/>\n```\n\nAG Grid equivalent: `onCellValueChanged: ({ data, colDef, newValue, oldValue }) => ...`.\nSvGrid's event payload is column-id + row-index based rather than node-based; the row data is yours\nto mutate (or not) on the `rows` array you passed in.\n\n### Grouping + aggregation\n\n```svelte\n<script lang=\"ts\">\n import {\n SvGrid,\n tableFeatures,\n columnGroupingFeature,\n rowSortingFeature,\n rowExpandingFeature,\n } from '@svgrid/grid'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n })\n\n const columns = [\n { field: 'department', header: 'Department' },\n { field: 'team', header: 'Team' },\n { field: 'salary', header: 'Salary', aggregate: 'sum',\n format: { type: 'currency', currency: 'USD' } },\n ]\n\n function setGroup(api) {\n api.setGroupBy(['department', 'team'])\n }\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n showGroupingControls\n onApiReady={setGroup}\n/>\n```\n\nAG Grid Enterprise's `rowGroupPanelShow: 'always'` + `aggFunc: 'sum'` translates 1:1.\n\n### Master / detail\n\nAG Grid Enterprise feature; **free in SvGrid Community**. See\n[demo 08](https://svgrid.com/demos/08-tree-and-master-detail/) for the exact pattern.\n\n### Server-side data\n\nAG Grid's Infinite Row Model (Community) and Server-Side Row Model\n(Enterprise) both take an `IServerSideDatasource` with a `getRows(params)`\nthat answers through `params.success({ rowData, rowCount })`. SvGrid has one\n`ServerDataSource` whose `getRows(request)` returns `{ rows, rowCount }`,\nand two controllers over it: `createServerDataSource` (free: paging or\ninfinite block scroll, sort, filter, CRUD) and `createServerRowModel`\n(Enterprise: lazy grouping, tree, pivot, transactions, selection). Both\nmount through one prop, `<SvGrid rowModel={ctl} />`.\n\nAn existing datasource written for AG Grid keeps working behind one call:\n\n```ts\nimport { adaptCallbackDatasource, createServerRowModel } from '@svgrid/enterprise'\n\nconst ctl = createServerRowModel(adaptCallbackDatasource(myServerSideDatasource), {\n groupBy: ['country'],\n aggregations: [{ col: 'amount', fn: 'sum' }],\n})\n```\n\n`adaptCallbackDatasource` maps the request both ways (`rowGroupCols` ->\n`groupBy`, `valueCols` -> `aggregations`, `pivotCols` -> `pivotBy`, the\nsort and filter models, `context`, `parentNode`) and turns `success` /\n`fail` into a resolved or rejected promise.\n\nMore often the datasource is a thin `fetch` and the real investment is the\nendpoint behind it - a Java or Node service that parses AG Grid's\n`IServerSideGetRowsRequest` JSON. That endpoint stays as it is: post\n`toCallbackRequest(request)` and read the answer back.\n\n```ts\nimport { toCallbackRequest } from '@svgrid/enterprise'\n\nconst source = {\n async getRows(request) {\n const res = await fetch('/api/sales', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(toCallbackRequest(request)),\n })\n const { rowData, rowCount, pivotResultFields } = await res.json()\n return { rows: rowData, rowCount: rowCount ?? -1, pivotResultFields }\n },\n}\n```\n\n`fromCallbackRequest` is the other direction, for a SvelteKit endpoint that\nhas to keep serving an AG Grid client during the switch. A saved selection\nor a bulk endpoint written against `getServerSideSelectionState()` keeps\nits shape the same way: `toCallbackSelectionState(ctl.getSelectionState())`\nproduces `{ selectAll, toggledNodes }` (or the `nodeId` /\n`selectAllChildren` / `toggledNodes` tree), `ctl.setSelectionState` takes\nthat shape as it is when your group node ids are the group keys, and\n`fromCallbackSelectionState(state, { groupKey, isGroup })` maps them when\na `getRowId` gave group rows ids of their own. Option by option:\n\n| AG Grid | SvGrid |\n| --- | --- |\n| `rowModelType: 'infinite'` + `datasource` | `createServerDataSource(source, { mode: 'infinite' })` + `rowModel={ctl}` |\n| `rowModelType: 'serverSide'` + `serverSideDatasource` | `createServerRowModel(source, options)` + `rowModel={ctl}` |\n| `getRows(params)` + `params.success({ rowData, rowCount })` / `params.fail()` | `getRows(request)` returning `{ rows, rowCount }` / throwing |\n| `cacheBlockSize`, `maxBlocksInCache`, `maxConcurrentDatasourceRequests`, `blockLoadDebounceMillis` | `blockSize`, `maxBlocksInCache`, `maxConcurrentRequests`, `blockLoadDebounceMs` |\n| `infiniteInitialRowCount` / `serverSideInitialRowCount` | `initialRowCount` |\n| `rowCount: -1` (unknown) | `rowCount: -1` |\n| `refreshInfiniteCache()` / `purgeInfiniteCache()` | `ctl.refresh()` / `ctl.purge()` |\n| `request.rowGroupCols`, `groupKeys`, `valueCols`, `pivotCols`, `pivotMode` | `request.groupBy`, `groupKeys`, `aggregations`, `pivotBy`, `pivotMode` |\n| `getChildCount(data)` | `childCount: (row) => row.childCount` |\n| `isServerSideGroupOpenByDefault` | `isGroupOpenByDefault(route, row)` |\n| `getServerSideGroupLevelParams` | `levelParams(level, route)` |\n| `isServerSideGroup` / `getServerSideGroupKey` (tree) | `treeData: true` + `hasChildren` / `getRowId` |\n| `grandTotalRow` / `groupTotalRow` | `grandTotalRow` / `groupFooters` |\n| `refreshServerSide({ route, purge })` / `retryServerSideLoads()` | `ctl.refresh({ route, purge })` / `ctl.retryLoads()` |\n| `applyServerSideTransaction` / `applyServerSideTransactionAsync` / `flushServerSideAsyncTransactions` | `ctl.applyTransaction` / `applyTransactionAsync` / `flushAsyncTransactions` |\n| `applyServerSideRowData` | `ctl.applyRowData` |\n| `serverSideSortAllLevels`, `serverSideEnableClientSideSort`, `serverSideOnlyRefreshFilteredGroups` | `sortAllLevels`, `clientSideSort`, `onlyRefreshFilteredGroups` |\n| `getServerSideSelectionState` / `setServerSideSelectionState` | `ctl.getSelectionState()` / `setSelectionState()` (the `toggledNodes` shape is accepted; `toCallbackSelectionState` / `fromCallbackSelectionState` convert) |\n| `ServerSideTransactionResult.status`: `Applied`, `StoreNotFound`, `StoreLoading`, `StoreWaitingToLoad`, `StoreLoadingFailed`, `Cancelled` | `ServerTransactionResult.status`: the same six, lower camel case (`applied`, `storeNotFound`, ...) - a `switch` on the old spelling falls through silently |\n| `groupSelects: 'descendants'` | `selection: { groupSelects: 'descendants' }` |\n| `pivotResultFields` in `success`, `setPivotResultColumns`, `processPivotResultColDef` | `pivotResultFields` / `pivotResultColumns` in the result, `pivotResultColumn(field, def)` |\n| `pagination` + `paginateChildRows` | `pagination: { pageSize, paginateChildRows }` |\n| `getCacheBlockState()` / `getServerSideGroupLevelState()` | `ctl.getCacheState()` / `ctl.levelStates()` |\n| `serverSideDatasource.destroy()` | `source.destroy()` |\n\nTwo differences worth knowing before you start: SvGrid sends the global\nsearch box to the server as `filterModel.global` (AG Grid's quick filter is\nclient-side only and unsupported under its infinite and server-side models),\nand SvGrid has no viewport row model. See\n[Server grouping](./server/server-grouping.md) and\n[demo 467](https://svgrid.com/demos/467-server-row-model-1m/).\n\n### Excel / PDF export\n\nAG Grid: `gridApi.exportDataAsExcel({...})` (Enterprise-only).\nSvGrid: install `@svgrid/enterprise`, call `api.exportData({ format: 'xlsx', ... })`. See [Data export and printing](./export.md).\n\n```ts\nimport { installEnterprise, setLicenseKey } from '@svgrid/enterprise'\nsetLicenseKey('YOUR-LICENSE-KEY') // your Enterprise key\n\n// inside onApiReady:\nconst pro = installEnterprise(api)\nawait pro.exportData({ format: 'xlsx', filename: 'orders' })\n```\n\n## Gotchas - things that don't translate directly\n\n### 1. Per-column `editable: true`\nEditing is switched on at the grid level (`enableInlineEditing`) and\nnarrowed per column with `editable: boolean | (ctx) => boolean` on the\ncolumn definition, so AG Grid's per-column flag and its callback form both\nmap directly. What does not map is AG Grid's `editable` on a column\ngroup: set it on each child column.\n\n### 2. Column drag-to-reorder\nHeader drag is `enableColumnReorder` on `<SvGrid>`; `setColumnOrder`\non the API does the same from code. See\n[column moving](./columns/column-moving.md) for the events.\n\n### 3. AG Grid `valueGetter` chains\nAG Grid's `valueGetter` can read other column values via the API. In\nSvGrid, `fieldFn` only receives the row; if you need cross-column\ncomputed values, do it in the cell renderer with `ctx.row.original` or\ncompute the derived value upstream and store it in the row.\n\n### 4. `cellClass` / `rowClass` callbacks\nOn the roadmap. For now, render a wrapper element in your `cell` snippet\nwith the conditional class.\n\n### 5. The Status Bar / Side Bar / Tool Panels\nAG Grid's chrome (status bar with row count, side bar with filters and\ncolumns panels) doesn't exist in SvGrid - build it as plain Svelte\nmarkup around the grid. Most teams build their own anyway because\nAG Grid's defaults rarely match a polished design system.\n\n### 6. Set filter (the Excel-style funnel popup)\nSvGrid ships an Excel-style filter menu (free in Community). API surface\nis similar but not identical - see [Set filter](./filtering/set-filter.md).\n\n## When NOT to migrate\n\nBe honest. Stay on AG Grid if you:\n\n- **Use multiple frameworks** - AG Grid has React, Angular, Vue, Solid, Qwik, vanilla adapters. SvGrid is Svelte-only.\n- **Need a push-based viewport row model** - SvGrid's server-side row model covers grouping, tree, pivot, transactions and select-all across unloaded rows, and its real-time page merges deltas into loaded rows, but the server cannot push the visible window over a socket the way the viewport model does.\n- **Need custom tool panels** - SvGrid's tool panel is a fixed Columns + Filters pair. (Custom filter components are covered: a column's `customFilter` renders your own control in the filter menu, the floating filter row and the Filters tab.)\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.\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.)\n\nIf none of those apply: switching drops a per-developer Enterprise licence\nfor features that are in SvGrid's MIT core, cuts the bundle by the\ndifference in the facts box above, and gives you a Svelte-native API that\nplays well with runes.\n\n## Step-by-step migration\n\nA typical migration of a single grid takes 1-3 hours:\n\n1. **Install** - `pnpm add @svgrid/grid` (and `@svgrid/enterprise` if you need export).\n2. **Translate columnDefs** - use the mapping table above. Most columns are 1:1.\n3. **Wrap features** - figure out which AG Grid features you actually use; register only those in `tableFeatures({...})`.\n4. **Swap the component** - `<AgGridSvelte gridOptions={...}>` → `<SvGrid data={rows} columns={columns} features={features}>`.\n5. **Move event handlers** - AG Grid `onCellValueChanged` → SvGrid `onCellValueChange` (signature differs slightly, see above).\n6. **Move API calls** - AG Grid `gridApi.X()` → SvGrid `api.X()` per the API table.\n7. **Test interactions** - sort, filter, edit, select. Most \"just works.\"\n8. **Remove `ag-grid-*` packages** - `pnpm remove ag-grid-community ag-grid-svelte` etc. Inspect your bundle to confirm the drop; the facts box above has both measured sizes.\n\n## Need help migrating?\n\nEnterprise customers get **migration help included** with the support plan\n(architecture review, port one grid for you as a reference). Email\n`support@jqwidgets.com` after purchase, or `sales@jqwidgets.com` for\npre-sales questions.\n\n## Frequently asked questions\n\n### Is SvGrid a drop-in replacement for AG Grid in Svelte?\n\nNot a literal drop-in - there is no `ag-grid-svelte` shim to swap. But the\nconcepts map closely: column definitions, row models, sorting, filtering,\ngrouping, and an imperative API all have direct SvGrid equivalents, so most\nteams port a grid in 30 minutes to a day. It is a configuration translation,\nnot a rewrite.\n\n### What is the SvGrid equivalent of AG Grid Enterprise?\n\n`@svgrid/enterprise`. It adds Excel/PDF/styled-HTML export (CSV, TSV and JSON export are already free in the grid), a printable view, pivot\ntables and data import. The in-grid AI helpers are free in `@svgrid/grid`.\nIt is licensed per developer ($599 for the grid, $999 with the spreadsheet\nand Studio, both covering unlimited apps), and the\nCommunity package is MIT-licensed and free for commercial use.\n\n### Does SvGrid use Svelte 5 runes, or is it a wrapper?\n\nIt is Svelte-5-native. State is `$state` / `$derived` / `$effect` and cells\nrender through Svelte snippets - there is no React or Angular core underneath\nand no framework bridge to keep in sync.\n\n### Will my AG Grid bundle size shrink?\n\nAlmost always. The facts box at the top of this page has both packages\nmeasured the same way on the same date: the full SvGrid render component\nwith its stylesheet against `createGrid` with every AG Grid Community\nmodule registered. You add `@svgrid/enterprise` features only where you\nimport them, and the headless core on its own is a small fraction of the\nrender component.\n\n## More examples\n\n### AG Grid ↔ sv-grid side-by-side\n\nTwo real grids over the same dataset: the installed AG Grid Community on the left, sv-grid on the right. Same global filter drives both. Source code panels for either side.\n\n<div data-docs-demo=\"139-migration-from-ag-grid\" data-height=\"460\"></div>\n\n## See also\n\n- [Getting started](../getting-started.md) - full SvGrid walkthrough\n- [Why headless?](../why-headless.md) - the headless / render-component split\n- [Data export and printing](./export.md) - the `@svgrid/enterprise` feature pack\n- [Comparison: SvGrid vs AG Grid vs TanStack Table](./comparison.md) - the\n measured benchmark and the feature matrix\n- [SvGrid vs AG Grid comparison page](https://svgrid.com/compare/ag-grid/) -\n every claim with its source and date\n"
5676
5676
  },
5677
5677
  {
5678
5678
  "slug": "help/migrating-from-airtable",
@@ -5826,7 +5826,7 @@ export const docs = [
5826
5826
  "path": "docs/help/missing-features.md",
5827
5827
  "title": "Missing features",
5828
5828
  "section": "Help",
5829
- "markdown": "# Missing features\n\nAn honest accounting of what is **not yet built**, audited against the shipped\ndemo catalog. Most of what used to live here has shipped; the remaining gaps\nare small and clearly marked. Each entry has a rough effort estimate (S / M / L).\n\nShipped items are struck through with the demo or API that covers them, so you\ncan see both the trajectory and the (short) list of real gaps.\n\n## Columns\n\n| Gap | Status | Effort |\n| --- | ------ | ------ |\n| ~~`getRowId` prop~~ | **shipped** | ✓ |\n| ~~`cellClass(ctx)` / `rowClass(ctx)` callbacks~~ | **shipped** | ✓ |\n| ~~`getColumnWidths()` / `setColumnWidth()`~~ | **shipped** | ✓ |\n| ~~`setColumnPinning()` / `getColumnPinning()`~~ | **shipped** | ✓ |\n| ~~Header drag-to-reorder~~ | **shipped** - `enableColumnReorder`; demo `109-column-reorder-engine` | ✓ |\n| ~~Per-column disable sort / filter~~ | **shipped** - `sortable` / `filterable` on `ColumnDef` | ✓ |\n| ~~Column spanning~~ | **shipped** - cell merging via `MergeSpec` + `spreadsheetLayout` (demo `170`), **plus** declarative value-driven `colSpan` / `rowSpan` via `spansToMerges` | ✓ |\n\n## Rows\n\n| Gap | Status | Effort |\n| --- | ------ | ------ |\n| ~~Row pinning (top / bottom)~~ | **shipped** - `pinnedTopRows` / `pinnedBottomRows`; demos `107-pinned-rows`, `108-pinned-rows-engine` | ✓ |\n| ~~Row spanning (merged cells across rows)~~ | **shipped as cell merging** - `rowspan` in `MergeSpec`; demo `170-cell-merging` | ✓ |\n| ~~Full-width / detail row API~~ | **shipped** - `isDetailRow`; demo `106-detail-rows` | ✓ |\n| ~~Variable row height with `<SvGrid>`~~ | **shipped** - `rowHeight` accepts `(rowIndex) => px` | ✓ |\n| ~~Auto row height (measure content)~~ | **shipped** - `autoRowHeight` wraps cell text and measures each row, virtualization included | ✓ |\n| ~~`api.getDisplayedRows()`~~ | **shipped** | ✓ |\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` | ✓ |\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) | ✓ |\n\n## Cells\n\n| Gap | Status | Effort |\n| --- | ------ | ------ |\n| ~~Built-in tooltip API on `ColumnDef`~~ | **shipped** - `tooltip`; demo `85-tooltips-and-notes` | ✓ |\n| ~~Formula language~~ | **shipped** - a real module in `@svgrid/enterprise/sheet`: parser, evaluator, the common functions plus financial, math, statistics, text and date packs (the [formulas page](./spreadsheet-formulas.md) lists them), cross-sheet and whole-column refs, short-circuiting `IF` / `IFERROR`, a dependency graph for incremental recalc, and `translateFormula` / `fixupReferences`. Absolute refs are now absolute (the demo copies stripped `$`, so anything filled or copied was quietly wrong) | ✓ |\n| ~~Formula editor (bar, autocomplete, Name Box)~~ | **shipped** - `<SvFormulaBar>` shows the raw text behind the active cell, autocompletes function names closest-match-first with signature hints, and its Name Box jumps to an address or a defined name; `createNames` defines them. Demo `453` | ✓ |\n| ~~Per-cell number formats and styles~~ | **shipped** - an Excel format-string compiler (`compileNumberFormat`) plus a store keyed on row id so formatting survives a sort, wired to Ctrl+1, Ctrl+Shift+1..6 and Ctrl+B/I/U. Demo `453` | ✓ |\n| ~~AutoSum~~ | **shipped** - Alt+= over the run Excel would guess | ✓ |\n| ~~Reference highlighting~~ | **shipped** - the sheet shell outlines every cell a formula in progress refers to, each in its own colour, whether the formula is typed in the cell or in the bar | ✓ |\n| ~~Find-in-grid~~ | **shipped** - Ctrl+F marks every match (the current one more strongly) and matches what a cell shows as well as its value; demo `87-find-in-grid` | ✓ |\n| ~~Excel keyboard shortcuts~~ | **shipped** - `enableSheet()` binds Ctrl+Arrow (a run-boundary search, so it hops gaps), Ctrl+Shift+Arrow, Ctrl+A region-then-sheet, Ctrl+Space / Shift+Space, Ctrl+D / Ctrl+R, Ctrl+; and Ctrl+'; demo `452-excel-shortcuts`. Bind your own through the free `registerGridShortcuts` seam | ✓ |\n| ~~Reference-aware fill~~ | **shipped** - `enableSheet()` wires `translateFormula` into Ctrl+D / Ctrl+R, so a filled formula shifts its relative refs and keeps its pinned ones | ✓ |\n| ~~Paste Special~~ | **shipped** - values / formulas / formats / transpose / add-subtract-multiply-divide / skip-blanks, plus a `text/html` clipboard flavour so formats and formulas survive a round trip through Excel. `Ctrl+Shift+V` | ✓ |\n| ~~Find and Replace~~ | **shipped** - match case, whole cell, look in values or formulas, scope to the selection, Replace All as one undo. `Ctrl+H` | ✓ |\n| ~~Text to Columns, Remove Duplicates~~ | **shipped** - `textToColumns` / `splitText` / `guessDelimiter` split a column, quoted fields and doubled quotes included; `findDuplicates` / `removeDuplicates` report before they remove, and match case-insensitively as Excel does | done |\n| ~~Open and save spreadsheet files~~ | **shipped** - the shell's File tab opens an .xlsx, an .ods, an .xls or a .csv into the document (decided by the bytes, not the file name) and saves the document back as any of the first three, formulas, formats, sizes, hidden lines and sheets, panes, merges, validation, conditional formatting, protection, comments and names included as far as each format carries them; `documentToXlsx` / `documentFromXlsx`, `documentToOds` / `documentFromOds`, `documentToXls` / `documentFromXls` and `documentFromFile` in `@svgrid/enterprise/sheet` | done |\n| ~~Multi-sheet workbook~~ | **shipped** - `createWorkbook` holds named sheets that read each other, with the dependency graph and structural edits spanning them; `<SvSheetTabs>` plus Ctrl+PageUp/PageDown and Shift+F11 | done |\n| ~~Goal Seek~~ | **shipped** - `goalSeekCell` solves for the input that makes a formula hit a target, secant with a bisection fallback, restoring the input cell so a dialog can ask before applying | done |\n| ~~Tables with structured references~~ | **shipped** - `Orders[Amount]`, `[@Amount]`, `[#Headers]` / `[#Totals]` / `[#All]`, column spans and auto-expansion. Resolved at evaluation, so a total keeps covering rows added after it was typed | done |\n| ~~Rewriting formulas on a sheet RENAME~~ | **shipped** - `renameSheet` rewrites every qualified reference and defined name through the parser, so a string literal that happens to hold the old name is untouched and a new name with a space comes out quoted | ✓ |\n| ~~Insert / delete with reference fixup~~ | **shipped** - `Ctrl+Shift+Plus` / `Ctrl+Minus` rewrite every formula and named range through `fixupReferences` | ✓ |\n| ~~Freeze panes: the ROW half~~ | **shipped** - `frozenRows={N}` on the grid keeps the first N rows under the header, the grid's own rows still, with the virtual window skipping them; `applyFreeze` / `freezeAtActiveCell` set both halves through the api and the shell keeps the panes per sheet | ✓ |\n| ~~`Alt+Enter`, `F4`~~ | **shipped** - Alt+Enter is a line break in the grid's multiline text editor and turns on Wrap Text in the sheet, whose rows grow to fit; F4 turns the reference at the caret through its anchorings, in the cell and in the formula bar | ✓ |\n| ~~Notes~~ | **shipped** - `notes` prop + cell comments; demos `85-tooltips-and-notes`, `91-cell-comments` | ✓ |\n| ~~Built-in cell flash / animated change highlight~~ | **shipped** - `cellFlash` on `ColumnDef` | ✓ |\n| ~~Drag a selected range to move / copy it~~ | **shipped** - `moveCells`, on by default with cell selection; demo `429-move-cells` | ✓ |\n| ~~Render inside a shadow root~~ | **shipped** - `<sv-grid-shadow>` in `@svgrid/grid-wc`. Open roots only; closed is unsupported, because a null `shadowRoot` leaves you unable to style, query or test your own grid | ✓ |\n| Eight draggable resize handles on a selection | **declined** - shift-click, shift-arrow, pointer drag and Ctrl+drag already resize a range four ways. Eight more hit targets per range in the hottest render path buys an interaction users already have. | - |\n\n## Export / Print\n\n| Gap | Status | Effort |\n| --- | ------ | ------ |\n| ~~Excel / xlsx, PDF, styled-HTML export, Print~~ | **shipped** in `@svgrid/enterprise` (CSV / TSV / JSON export is free in `@svgrid/grid`) - demos `21`, `56`-`59`, `93`, `101`, `119`, `126`, `127` | ✓ |\n\n## Filtering\n\n| Gap | Status | Effort |\n| --- | ------ | ------ |\n| ~~`between` operator in the column menu~~ | **shipped** - demo `64-filter-between-operator` | ✓ |\n| ~~Set filter (tree-list, async, Excel-mode)~~ | **shipped** - demo `111-set-filter-advanced` | ✓ |\n| ~~Locale-aware text filtering~~ | **shipped** - demo `110-locale-aware-filter` | ✓ |\n| ~~`clearAllFilters()` / `getFilters()`~~ | **shipped** | ✓ |\n| ~~Floating filters (per-operator)~~ | **shipped** - filter row honours every operator per column with typed inputs + inline `between`; demo `179` | ✓ |\n| ~~Multi-condition filter within one column (AND / OR)~~ | **shipped** - two conditions per column via the funnel or `api.setFilter`; demo `178` | ✓ |\n\n## Editing\n\n| Gap | Status | Effort |\n| --- | ------ | ------ |\n| ~~`cellEditor` slot for custom inline editors~~ | **shipped** - demos `84-editor-types`, `66-custom-cell-editors` | ✓ |\n| ~~Built-in select & rich-select editors~~ | **shipped** - `editorType: 'list' / 'rich-select'`; demo `84-editor-types` | ✓ |\n| ~~Built-in large-text (textarea) editor~~ | **shipped** - demo `84-editor-types` | ✓ |\n| ~~Per-column `validate()`~~ | **shipped** - demos `24-validation`, `103-async-validation` | ✓ |\n| ~~Built-in undo / redo stack~~ | **shipped** - `api.undo()` / `redo()`; demo `86-undo-redo` | ✓ |\n| ~~Batch / staged editing mode~~ | **shipped** - demo `88-staged-editing` | ✓ |\n| ~~Per-column `valueParser`~~ | **shipped** - `valueParser` on `ColumnDef`; demo `175` | ✓ |\n| ~~Programmatic `api.startEditing()` / `stopEditing()`~~ | **shipped** - demo `176` | ✓ |\n| ~~Full-row editing mode~~ | **shipped** - `fullRowEditing`; demo `177` | ✓ |\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` | ✓ |\n\n## The real remaining gaps (short list)\n\nThe previous round shipped declarative col/row spanning, cell flash,\n`valueParser`, programmatic start/stop editing, full-row editing, multi-condition\nfilters, per-operator floating filters, and managed grid-to-grid row dragging -\nall with demos and docs. What is left is a short list of AG-Grid-Enterprise\nparity items, mostly UX affordances on top of engines that already exist:\n\nAudited against the code and the 171-demo catalog (four-way inventory, June 2026).\nThis list is deliberately short - most AG-Grid-Enterprise parity items already\nship (row-group panel `89`, status bar `144`, tool panel `146`, pivot + designer,\nserver-side row model `148`, export with images/styles `56`/`58`, charts,\nsparklines, collaboration). The genuine remaining gaps:\n\n| Gap | What exists today | Effort |\n| --- | ----------------- | ------ |\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` | ✓ |\n| ~~**Cell data-type inference** (`cellDataType`)~~ | **shipped** - `cellDataType` on `ColumnDef` + grid-level `inferColumnTypes` | ✓ |\n| ~~**Merged-cell export to xlsx**~~ | **shipped** - `merges` option on `exportData` (single-sheet), lines up with `MergeSpec` | ✓ |\n| ~~**Filters tool panel tab**~~ | **shipped** - Columns \\| Filters tabs in the tool panel (`146`), in sync with the column menu | ✓ |\n| ~~**Copy with headers**~~ | **shipped** - `copyHeadersToClipboard` + `processCellForClipboard` hook | ✓ |\n| ~~**Aligned grids**~~ | **shipped** - `alignedGridGroup` syncs horizontal scroll + column-resize widths; demo `182` | ✓ |\n| ~~**Collapsible column groups**~~ | **shipped** - `columnGroupShow: 'open' \\| 'closed'` + `openByDefault`; demo `183` | ✓ |\n| ~~**Column menu tabs** (General / Filter / Columns)~~ | **shipped** - tabbed column menu; demo any filterable grid | ✓ |\n| ~~**External row-drag drop zones**~~ | **shipped** - `rowDropZone` action (drop rows onto any element); demo `184` | ✓ |\n| ~~**Nested master/detail grids**~~ | **shipped** - `isDetailRow` + `renderDetailRow` hosting a child grid; demo `181` | ✓ |\n\n### Still open (medium / large)\n\n| Gap | Note | Effort |\n| --- | ---- | ------ |\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` | ✓ |\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 |\n| **Custom tool panels** | panel is fixed Columns + Filters (`toolPanelDefaultTab` is a `\"columns\" \\| \"filters\"` union, with no registry) | M |\n| ~~**UI-string localisation**~~ | **shipped** - the `localization` prop takes `{ locale, text }`, where `text` is a partial `GridMessages`; every menu/panel/chrome string is overridable. Note there is no `localeText` prop, and no translation catalogues ship in the box - you supply the strings. See [i18n and RTL](./i18n-rtl.md) | ✓ |\n| ~~**Row-grouping display modes** + group-level footers~~ | **shipped** - `groupDisplayMode: 'groupRows' \\| 'singleColumn' \\| 'multipleColumns'` plus `groupFooters` and `grandTotalRow`; demo `427-group-footers` | ✓ |\n| ~~**In-grid pivot mode** (toggle on the main grid)~~ | **shipped** - `enablePivot()` registers the engine and the main grid pivots in place | ✓ |\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. The picker now reaches all 15 types (it offered 4), gated on whether the current columns can feed each one, and `--sg-chart-*` tokens let a theme recolour the series | ✓ |\n| ~~**Chart depth program** (axis model, 13 more series types, interaction, financial toolkit, builder)~~ | **shipped** 12 Sep 2026, all free in `@svgrid/grid` - per-axis config with a numeric x axis and reference bands; histogram, range, lollipop, dumbbell, pareto, stream, sunburst, radial, nightingale, chord and bullet; wheel / pinch / pan zoom, range presets, `syncGroup`, a context menu, animation, drilldown, point selection, LTTB decimation; eight indicators with `SvChartPanes`, `rowsToOhlcSpec` + resampling, a last-price line, flags, drawing tools; PDF and print; the in-panel builder with a type gallery, Format tab and link / unlink; `<sv-chart>` with React / Vue / Angular wrappers. Demos 434 to 437 | ✓ |\n| ~~**Server-side pivot**~~ | **shipped** (Enterprise) - `createServerRowModel` sends `pivotBy` + `pivotMode`, builds the columns from `pivotResultFields`, and `SvPivotDesigner` drives it in server mode; see [Server pivot](./server/server-pivot.md) | ✓ |\n| **Viewport row model** (the server pushes the visible window over a socket) | not built; [real-time](./real-time.md) merges deltas into loaded rows instead, and a socket can push a whole level through `applyRowData` | M |\n| **3D charts** | not built; the 29 chart types are all 2D | L |\n| **More render modes, starting with Canvas** | not built; the grid renders to the DOM, with row and column virtualization | L |\n| **Spreadsheet gaps** (track changes, page layout, chart formatting, pivot field list and slicers, Data > Subtotal, server-backed collaboration) | listed with what ships today under [What it does not do](./cells/spreadsheet-shell.md#what-it-does-not-do) | M to L |\n\n## What's already there\n\nThe stable, built-in feature surface is large. Highlights: sorting (single +\nmulti), per-column filtering (menu + row + global) with a `between` range\noperator and set/tree/async filters, pagination, grouping + aggregation, tree\ndata, master/detail + full-width detail rows, row + column virtualization\n(100k+ and a 1M-row demo), cell-range selection + copy/paste + Excel-style fill\nhandle + drag-the-border move/copy, inline editing with 14 editor types plus a custom `cellEditor` slot,\nundo/redo, staged editing, find-in-grid, notes + cell comments, tooltips,\nconditional formatting, sparklines, cell merging, column pinning/reorder/resize,\nrow pinning, a formula engine (+ HyperFormula adapter), a server-side row model (flat paging and infinite scroll free; grouping, tree, pivot, transactions and selection across unloaded rows in Enterprise),\nExcel/PDF/CSV/HTML export + print (Enterprise), pivot + charts + AI (Enterprise),\nWAI-ARIA + keyboard nav, RTL, i18n, theming via `--sg-*` tokens, SSR, and a\nCSP-clean runtime.\n\n## How to contribute\n\n1. Pick a gap from **The real remaining gaps** above.\n2. Open an issue describing the API you'd want - names, types, the minimal change.\n3. If you can write the patch, do so, and keep tests with the change.\n"
5829
+ "markdown": "# Missing features\n\nAn honest accounting of what is **not yet built**, audited against the shipped\ndemo catalog. Most of what used to live here has shipped; the remaining gaps\nare small and clearly marked. Each entry has a rough effort estimate (S / M / L).\n\nShipped items are struck through with the demo or API that covers them, so you\ncan see both the trajectory and the (short) list of real gaps.\n\n## Columns\n\n| Gap | Status | Effort |\n| --- | ------ | ------ |\n| ~~`getRowId` prop~~ | **shipped** | ✓ |\n| ~~`cellClass(ctx)` / `rowClass(ctx)` callbacks~~ | **shipped** | ✓ |\n| ~~`getColumnWidths()` / `setColumnWidth()`~~ | **shipped** | ✓ |\n| ~~`setColumnPinning()` / `getColumnPinning()`~~ | **shipped** | ✓ |\n| ~~Header drag-to-reorder~~ | **shipped** - `enableColumnReorder`; demo `109-column-reorder-engine` | ✓ |\n| ~~Per-column disable sort / filter~~ | **shipped** - `sortable` / `filterable` on `ColumnDef` | ✓ |\n| ~~Column spanning~~ | **shipped** - cell merging via `MergeSpec` + `spreadsheetLayout` (demo `170`), **plus** declarative value-driven `colSpan` / `rowSpan` via `spansToMerges` | ✓ |\n\n## Rows\n\n| Gap | Status | Effort |\n| --- | ------ | ------ |\n| ~~Row pinning (top / bottom)~~ | **shipped** - `pinnedTopRows` / `pinnedBottomRows`; demos `107-pinned-rows`, `108-pinned-rows-engine` | ✓ |\n| ~~Row spanning (merged cells across rows)~~ | **shipped as cell merging** - `rowspan` in `MergeSpec`; demo `170-cell-merging` | ✓ |\n| ~~Full-width / detail row API~~ | **shipped** - `isDetailRow`; demo `106-detail-rows` | ✓ |\n| ~~Variable row height with `<SvGrid>`~~ | **shipped** - `rowHeight` accepts `(rowIndex) => px` | ✓ |\n| ~~Auto row height (measure content)~~ | **shipped** - `autoRowHeight` wraps cell text and measures each row, virtualization included | ✓ |\n| ~~`api.getDisplayedRows()`~~ | **shipped** | ✓ |\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` | ✓ |\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) | ✓ |\n\n## Cells\n\n| Gap | Status | Effort |\n| --- | ------ | ------ |\n| ~~Built-in tooltip API on `ColumnDef`~~ | **shipped** - `tooltip`; demo `85-tooltips-and-notes` | ✓ |\n| ~~Formula language~~ | **shipped** - a real module in `@svgrid/enterprise/sheet`: parser, evaluator, the common functions plus financial, math, statistics, text and date packs (the [formulas page](./spreadsheet-formulas.md) lists them), cross-sheet and whole-column refs, short-circuiting `IF` / `IFERROR`, a dependency graph for incremental recalc, and `translateFormula` / `fixupReferences`. Absolute refs are now absolute (the demo copies stripped `$`, so anything filled or copied was quietly wrong) | ✓ |\n| ~~Formula editor (bar, autocomplete, Name Box)~~ | **shipped** - `<SvFormulaBar>` shows the raw text behind the active cell, autocompletes function names closest-match-first with signature hints, and its Name Box jumps to an address or a defined name; `createNames` defines them. Demo `453` | ✓ |\n| ~~Per-cell number formats and styles~~ | **shipped** - an Excel format-string compiler (`compileNumberFormat`) plus a store keyed on row id so formatting survives a sort, wired to Ctrl+1, Ctrl+Shift+1..6 and Ctrl+B/I/U. Demo `453` | ✓ |\n| ~~AutoSum~~ | **shipped** - Alt+= over the run Excel would guess | ✓ |\n| ~~Reference highlighting~~ | **shipped** - the sheet shell outlines every cell a formula in progress refers to, each in its own colour, whether the formula is typed in the cell or in the bar | ✓ |\n| ~~Find-in-grid~~ | **shipped** - Ctrl+F marks every match (the current one more strongly) and matches what a cell shows as well as its value; demo `87-find-in-grid` | ✓ |\n| ~~Excel keyboard shortcuts~~ | **shipped** - `enableSheet()` binds Ctrl+Arrow (a run-boundary search, so it hops gaps), Ctrl+Shift+Arrow, Ctrl+A region-then-sheet, Ctrl+Space / Shift+Space, Ctrl+D / Ctrl+R, Ctrl+; and Ctrl+'; demo `452-excel-shortcuts`. Bind your own through the free `registerGridShortcuts` seam | ✓ |\n| ~~Reference-aware fill~~ | **shipped** - `enableSheet()` wires `translateFormula` into Ctrl+D / Ctrl+R, so a filled formula shifts its relative refs and keeps its pinned ones | ✓ |\n| ~~Paste Special~~ | **shipped** - values / formulas / formats / transpose / add-subtract-multiply-divide / skip-blanks, plus a `text/html` clipboard flavour so formats and formulas survive a round trip through Excel. `Ctrl+Shift+V` | ✓ |\n| ~~Find and Replace~~ | **shipped** - match case, whole cell, look in values or formulas, scope to the selection, Replace All as one undo. `Ctrl+H` | ✓ |\n| ~~Text to Columns, Remove Duplicates~~ | **shipped** - `textToColumns` / `splitText` / `guessDelimiter` split a column, quoted fields and doubled quotes included; `findDuplicates` / `removeDuplicates` report before they remove, and match case-insensitively as Excel does | done |\n| ~~Open and save spreadsheet files~~ | **shipped** - the shell's File tab opens an .xlsx, an .ods, an .xls or a .csv into the document (decided by the bytes, not the file name) and saves the document back as any of the first three, formulas, formats, sizes, hidden lines and sheets, panes, merges, validation, conditional formatting, protection, comments and names included as far as each format carries them; `documentToXlsx` / `documentFromXlsx`, `documentToOds` / `documentFromOds`, `documentToXls` / `documentFromXls` and `documentFromFile` in `@svgrid/enterprise/sheet` | done |\n| ~~Multi-sheet workbook~~ | **shipped** - `createWorkbook` holds named sheets that read each other, with the dependency graph and structural edits spanning them; `<SvSheetTabs>` plus Ctrl+PageUp/PageDown and Shift+F11 | done |\n| ~~Goal Seek~~ | **shipped** - `goalSeekCell` solves for the input that makes a formula hit a target, secant with a bisection fallback, restoring the input cell so a dialog can ask before applying | done |\n| ~~Tables with structured references~~ | **shipped** - `Orders[Amount]`, `[@Amount]`, `[#Headers]` / `[#Totals]` / `[#All]`, column spans and auto-expansion. Resolved at evaluation, so a total keeps covering rows added after it was typed | done |\n| ~~Rewriting formulas on a sheet RENAME~~ | **shipped** - `renameSheet` rewrites every qualified reference and defined name through the parser, so a string literal that happens to hold the old name is untouched and a new name with a space comes out quoted | ✓ |\n| ~~Insert / delete with reference fixup~~ | **shipped** - `Ctrl+Shift+Plus` / `Ctrl+Minus` rewrite every formula and named range through `fixupReferences` | ✓ |\n| ~~Freeze panes: the ROW half~~ | **shipped** - `frozenRows={N}` on the grid keeps the first N rows under the header, the grid's own rows still, with the virtual window skipping them; `applyFreeze` / `freezeAtActiveCell` set both halves through the api and the shell keeps the panes per sheet | ✓ |\n| ~~`Alt+Enter`, `F4`~~ | **shipped** - Alt+Enter is a line break in the grid's multiline text editor and turns on Wrap Text in the sheet, whose rows grow to fit; F4 turns the reference at the caret through its anchorings, in the cell and in the formula bar | ✓ |\n| ~~Notes~~ | **shipped** - `notes` prop + cell comments; demos `85-tooltips-and-notes`, `91-cell-comments` | ✓ |\n| ~~Built-in cell flash / animated change highlight~~ | **shipped** - `cellFlash` on `ColumnDef` | ✓ |\n| ~~Drag a selected range to move / copy it~~ | **shipped** - `moveCells`, on by default with cell selection; demo `429-move-cells` | ✓ |\n| ~~Render inside a shadow root~~ | **shipped** - `<sv-grid-shadow>` in `@svgrid/grid-wc`. Open roots only; closed is unsupported, because a null `shadowRoot` leaves you unable to style, query or test your own grid | ✓ |\n| Eight draggable resize handles on a selection | **declined** - shift-click, shift-arrow, pointer drag and Ctrl+drag already resize a range four ways. Eight more hit targets per range in the hottest render path buys an interaction users already have. | - |\n\n## Export / Print\n\n| Gap | Status | Effort |\n| --- | ------ | ------ |\n| ~~Excel / xlsx, PDF, styled-HTML export, Print~~ | **shipped** in `@svgrid/enterprise` (CSV / TSV / JSON export is free in `@svgrid/grid`) - demos `21`, `56`-`59`, `93`, `101`, `119`, `126`, `127` | ✓ |\n\n## Filtering\n\n| Gap | Status | Effort |\n| --- | ------ | ------ |\n| ~~`between` operator in the column menu~~ | **shipped** - demo `64-filter-between-operator` | ✓ |\n| ~~Set filter (tree-list, async, Excel-mode)~~ | **shipped** - demo `111-set-filter-advanced` | ✓ |\n| ~~Locale-aware text filtering~~ | **shipped** - demo `110-locale-aware-filter` | ✓ |\n| ~~`clearAllFilters()` / `getFilters()`~~ | **shipped** | ✓ |\n| ~~Floating filters (per-operator)~~ | **shipped** - filter row honours every operator per column with typed inputs + inline `between`; demo `179` | ✓ |\n| ~~Multi-condition filter within one column (AND / OR)~~ | **shipped** - two conditions per column via the funnel or `api.setFilter`; demo `178` | ✓ |\n\n## Editing\n\n| Gap | Status | Effort |\n| --- | ------ | ------ |\n| ~~`cellEditor` slot for custom inline editors~~ | **shipped** - demos `84-editor-types`, `66-custom-cell-editors` | ✓ |\n| ~~Built-in select & rich-select editors~~ | **shipped** - `editorType: 'list' / 'rich-select'`; demo `84-editor-types` | ✓ |\n| ~~Built-in large-text (textarea) editor~~ | **shipped** - demo `84-editor-types` | ✓ |\n| ~~Per-column `validate()`~~ | **shipped** - demos `24-validation`, `103-async-validation` | ✓ |\n| ~~Built-in undo / redo stack~~ | **shipped** - `api.undo()` / `redo()`; demo `86-undo-redo` | ✓ |\n| ~~Batch / staged editing mode~~ | **shipped** - demo `88-staged-editing` | ✓ |\n| ~~Per-column `valueParser`~~ | **shipped** - `valueParser` on `ColumnDef`; demo `175` | ✓ |\n| ~~Programmatic `api.startEditing()` / `stopEditing()`~~ | **shipped** - demo `176` | ✓ |\n| ~~Full-row editing mode~~ | **shipped** - `fullRowEditing`; demo `177` | ✓ |\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` | ✓ |\n\n## The real remaining gaps (short list)\n\nThe previous round shipped declarative col/row spanning, cell flash,\n`valueParser`, programmatic start/stop editing, full-row editing, multi-condition\nfilters, per-operator floating filters, and managed grid-to-grid row dragging -\nall with demos and docs. What is left is a short list of AG-Grid-Enterprise\nparity items, mostly UX affordances on top of engines that already exist:\n\nAudited against the code and the 171-demo catalog (four-way inventory, June 2026).\nThis list is deliberately short - most AG-Grid-Enterprise parity items already\nship (row-group panel `89`, status bar `144`, tool panel `146`, pivot + designer,\nserver-side row model `148`, export with images/styles `56`/`58`, charts,\nsparklines, collaboration). The genuine remaining gaps:\n\n| Gap | What exists today | Effort |\n| --- | ----------------- | ------ |\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` | ✓ |\n| ~~**Cell data-type inference** (`cellDataType`)~~ | **shipped** - `cellDataType` on `ColumnDef` + grid-level `inferColumnTypes` | ✓ |\n| ~~**Merged-cell export to xlsx**~~ | **shipped** - `merges` option on `exportData` (single-sheet), lines up with `MergeSpec` | ✓ |\n| ~~**Filters tool panel tab**~~ | **shipped** - Columns \\| Filters tabs in the tool panel (`146`), in sync with the column menu | ✓ |\n| ~~**Copy with headers**~~ | **shipped** - `copyHeadersToClipboard` + `processCellForClipboard` hook | ✓ |\n| ~~**Aligned grids**~~ | **shipped** - `alignedGridGroup` syncs horizontal scroll + column-resize widths; demo `182` | ✓ |\n| ~~**Collapsible column groups**~~ | **shipped** - `columnGroupShow: 'open' \\| 'closed'` + `openByDefault`; demo `183` | ✓ |\n| ~~**Column menu tabs** (General / Filter / Columns)~~ | **shipped** - tabbed column menu; demo any filterable grid | ✓ |\n| ~~**External row-drag drop zones**~~ | **shipped** - `rowDropZone` action (drop rows onto any element); demo `184` | ✓ |\n| ~~**Nested master/detail grids**~~ | **shipped** - `isDetailRow` + `renderDetailRow` hosting a child grid; demo `181` | ✓ |\n\n### Still open (medium / large)\n\n| Gap | Note | Effort |\n| --- | ---- | ------ |\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` | ✓ |\n| ~~**Custom filter / floating-filter component** slot~~ | **shipped** - a column's `customFilter` takes a `predicate(model)` plus `menu` / `floating` snippets; the model is in `api.setCustomFilter`, `getState().customFilters` and `onFiltersChange`'s `custom`. See [Custom column filters](./filtering/custom-column-filters.md) | ✓ |\n| **Custom tool panels** | panel is fixed Columns + Filters (`toolPanelDefaultTab` is a `\"columns\" \\| \"filters\"` union, with no registry) | M |\n| ~~**UI-string localisation**~~ | **shipped** - the `localization` prop takes `{ locale, text }`, where `text` is a partial `GridMessages`; every menu/panel/chrome string is overridable. Note there is no `localeText` prop, and no translation catalogues ship in the box - you supply the strings. See [i18n and RTL](./i18n-rtl.md) | ✓ |\n| ~~**Row-grouping display modes** + group-level footers~~ | **shipped** - `groupDisplayMode: 'groupRows' \\| 'singleColumn' \\| 'multipleColumns'` plus `groupFooters` and `grandTotalRow`; demo `427-group-footers` | ✓ |\n| ~~**In-grid pivot mode** (toggle on the main grid)~~ | **shipped** - `enablePivot()` registers the engine and the main grid pivots in place | ✓ |\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. The picker now reaches all 15 types (it offered 4), gated on whether the current columns can feed each one, and `--sg-chart-*` tokens let a theme recolour the series | ✓ |\n| ~~**Chart depth program** (axis model, 13 more series types, interaction, financial toolkit, builder)~~ | **shipped** 12 Sep 2026, all free in `@svgrid/grid` - per-axis config with a numeric x axis and reference bands; histogram, range, lollipop, dumbbell, pareto, stream, sunburst, radial, nightingale, chord and bullet; wheel / pinch / pan zoom, range presets, `syncGroup`, a context menu, animation, drilldown, point selection, LTTB decimation; eight indicators with `SvChartPanes`, `rowsToOhlcSpec` + resampling, a last-price line, flags, drawing tools; PDF and print; the in-panel builder with a type gallery, Format tab and link / unlink; `<sv-chart>` with React / Vue / Angular wrappers. Demos 434 to 437 | ✓ |\n| ~~**Server-side pivot**~~ | **shipped** (Enterprise) - `createServerRowModel` sends `pivotBy` + `pivotMode`, builds the columns from `pivotResultFields`, and `SvPivotDesigner` drives it in server mode; see [Server pivot](./server/server-pivot.md) | ✓ |\n| **Viewport row model** (the server pushes the visible window over a socket) | not built; [real-time](./real-time.md) merges deltas into loaded rows instead, and a socket can push a whole level through `applyRowData` | M |\n| **3D charts** | not built; the 29 chart types are all 2D | L |\n| **More render modes, starting with Canvas** | not built; the grid renders to the DOM, with row and column virtualization | L |\n| **Spreadsheet gaps** (track changes, page layout, chart formatting, pivot field list and slicers, Data > Subtotal, server-backed collaboration) | listed with what ships today under [What it does not do](./cells/spreadsheet-shell.md#what-it-does-not-do) | M to L |\n\n## What's already there\n\nThe stable, built-in feature surface is large. Highlights: sorting (single +\nmulti), per-column filtering (menu + row + global) with a `between` range\noperator and set/tree/async filters, pagination, grouping + aggregation, tree\ndata, master/detail + full-width detail rows, row + column virtualization\n(100k+ and a 1M-row demo), cell-range selection + copy/paste + Excel-style fill\nhandle + drag-the-border move/copy, inline editing with 14 editor types plus a custom `cellEditor` slot,\nundo/redo, staged editing, find-in-grid, notes + cell comments, tooltips,\nconditional formatting, sparklines, cell merging, column pinning/reorder/resize,\nrow pinning, a formula engine (+ HyperFormula adapter), a server-side row model (flat paging and infinite scroll free; grouping, tree, pivot, transactions and selection across unloaded rows in Enterprise),\nExcel/PDF/CSV/HTML export + print (Enterprise), pivot + charts + AI (Enterprise),\nWAI-ARIA + keyboard nav, RTL, i18n, theming via `--sg-*` tokens, SSR, and a\nCSP-clean runtime.\n\n## How to contribute\n\n1. Pick a gap from **The real remaining gaps** above.\n2. Open an issue describing the API you'd want - names, types, the minimal change.\n3. If you can write the patch, do so, and keep tests with the change.\n"
5830
5830
  },
5831
5831
  {
5832
5832
  "slug": "help/mobile-card-view",
@@ -8346,8 +8346,8 @@ export const apiReference = {
8346
8346
  ]
8347
8347
  };
8348
8348
  export const apiSurface = {
8349
- "gridVersion": "3.0.16",
8350
- "enterpriseVersion": "3.1.15",
8349
+ "gridVersion": "3.0.19",
8350
+ "enterpriseVersion": "3.1.18",
8351
8351
  "grid": {
8352
8352
  "values": [
8353
8353
  "ALL_FILTER_OPERATORS",
@@ -9165,6 +9165,8 @@ export const apiSurface = {
9165
9165
  "CountryInput",
9166
9166
  "CountryInputConfig",
9167
9167
  "CsvOptions",
9168
+ "CustomColumnFilter",
9169
+ "CustomFilterContext",
9168
9170
  "DataBarFormat",
9169
9171
  "DateLike",
9170
9172
  "DateRangeValue",
@@ -11614,6 +11616,11 @@ export const apiSurface = {
11614
11616
  "optional": true,
11615
11617
  "type": "boolean"
11616
11618
  },
11619
+ {
11620
+ "name": "customFilter",
11621
+ "optional": true,
11622
+ "type": "CustomColumnFilter<TData>"
11623
+ },
11617
11624
  {
11618
11625
  "name": "editorOptions",
11619
11626
  "optional": true,
@@ -11883,6 +11890,7 @@ export const apiSurface = {
11883
11890
  "getColumnWidths",
11884
11891
  "getColumns",
11885
11892
  "getCommandContext",
11893
+ "getCustomFilter",
11886
11894
  "getData",
11887
11895
  "getDisplayedRows",
11888
11896
  "getElement",
@@ -11936,6 +11944,7 @@ export const apiSurface = {
11936
11944
  "setColumnPinning",
11937
11945
  "setColumnVisible",
11938
11946
  "setColumnWidth",
11947
+ "setCustomFilter",
11939
11948
  "setFacetFilter",
11940
11949
  "setFilter",
11941
11950
  "setFindQuery",