@svgrid/mcp 3.0.22 → 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/README.md +9 -0
- package/dist/core-tools.d.ts +1 -66
- package/dist/core-tools.js +1 -65
- package/dist/data.js +6 -6
- package/dist/index.js +106 -33
- package/dist/preview.d.ts +2 -49
- package/dist/preview.js +3 -32
- package/dist/tool-defs.d.ts +122 -0
- package/dist/tool-defs.js +104 -0
- package/package.json +12 -4
- package/server.json +2 -2
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&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 · \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&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 · \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",
|
|
@@ -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\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\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\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\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\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\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",
|
|
@@ -5665,7 +5665,7 @@ 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\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\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",
|
|
@@ -8346,8 +8346,8 @@ export const apiReference = {
|
|
|
8346
8346
|
]
|
|
8347
8347
|
};
|
|
8348
8348
|
export const apiSurface = {
|
|
8349
|
-
"gridVersion": "3.0.
|
|
8350
|
-
"enterpriseVersion": "3.1.
|
|
8349
|
+
"gridVersion": "3.0.19",
|
|
8350
|
+
"enterpriseVersion": "3.1.18",
|
|
8351
8351
|
"grid": {
|
|
8352
8352
|
"values": [
|
|
8353
8353
|
"ALL_FILTER_OPERATORS",
|