@svgrid/mcp 3.0.8 → 3.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/data.js CHANGED
@@ -1021,7 +1021,7 @@ export const examples = [
1021
1021
  "title": "Column Pinning",
1022
1022
  "category": "Columns",
1023
1023
  "blurb": "25. Column pinning + freezing Wide grid (8 generous columns ~2050px total) so horizontal scrolling kicks in on any reasonable viewport. Company is pre-pinned to the",
1024
- "source": "<!-- Documented in: docs/help/recipes.md -->\n<script lang=\"ts\">\n /**\n * 25. Column pinning + freezing\n * -----------------------------\n * Wide grid (8 generous columns ~2050px total) so horizontal scrolling\n * kicks in on any reasonable viewport. Company is pre-pinned to the\n * LEFT and Unit price to the RIGHT - they stay sticky while the middle\n * columns slide under them when you scroll.\n *\n * Try it:\n * - Scroll the grid horizontally - Company stays anchored on the left,\n * Unit price on the right, the rest scroll between them.\n * - Hover any middle-column header → click the ⋮ that appears →\n * \"Pin to left\" / \"Pin to right\" / \"Unpin column\".\n *\n * Note: column pinning requires `columnVirtualization={false}` because\n * the virtualizer recycles DOM nodes (which breaks sticky positioning).\n * The pin menu items are hidden when virtualization is on, so the\n * gating is automatic.\n *\n * A programmatic `api.setColumnPinning(id, side)` is on the v1.x\n * roadmap; today the `initialColumnPinning` prop covers the on-mount\n * case and the column menu covers user-driven re-pinning.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n type GridColumns,\n } from '@svgrid/grid'\n import { makeOrders, type Order } from '../shared/seed'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n })\n\n let rows = $state<Order[]>(makeOrders(500))\n\n // 8 generous columns - total ~2050px so the grid overflows horizontally\n // on any normal monitor. Pinning is only meaningful when the grid\n // actually scrolls; the demo has to force that condition to demonstrate\n // the feature.\n const columns: GridColumns<Order> = [\n { field: 'company', header: 'Company', width: 240 },\n { field: 'product', header: 'Product', width: 320 },\n { field: 'orderId', header: 'Order ID', width: 180 },\n { field: 'country', header: 'Country', width: 200 },\n { field: 'sellDate', header: 'Sell date', width: 220,\n format: { type: 'date', pattern: 'y-m-d' } },\n { field: 'quantity', header: 'Quantity', width: 170,\n format: { type: 'number', options: { maximumFractionDigits: 0 } } },\n { field: 'inStock', header: 'In stock', width: 160 },\n { field: 'price', header: 'Unit price', width: 200,\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <div\n class=\"shrink-0 rounded-lg border px-4 py-3\"\n style=\"border-color: var(--sg-border); background: var(--sg-header-bg);\"\n >\n <p class=\"text-sm font-semibold\" style=\"color: var(--sg-fg);\">\n Company is pinned LEFT · Unit price is pinned RIGHT\n </p>\n <ol class=\"mt-1 text-xs list-decimal pl-5 space-y-0.5\" style=\"color: var(--sg-fg);\">\n <li>\n <strong>Scroll horizontally</strong> - Company and Unit price stay anchored at the edges\n while everything else slides between them.\n </li>\n <li>\n <strong>Pin another column</strong> - hover any middle header → click the\n <code style=\"color: var(--site-accent-2, #22d3ee);\">⋮</code> → pick\n <em>Pin to left</em>, <em>Pin to right</em>, or <em>Unpin column</em>.\n </li>\n </ol>\n </div>\n\n <div class=\"flex-1 min-h-0 pinning-host\">\n <SvGrid responsive={true}\n columnResize\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n showRowNumbers={true}\n showPagination={true}\n pageSize={50}\n enableCellSelection={true}\n rowHeight={36}\n containerHeight=\"100%\"\n fitColumns={false}\n columnVirtualization={false}\n initialColumnPinning={{ left: ['company'], right: ['price'] }}\n />\n </div>\n\n <footer class=\"pin-foot text-xs shrink-0\">\n {rows.length} rows · {columns.length} columns ·\n <code>fitColumns=&lbrace;false&rbrace;</code> +\n <code>columnVirtualization=&lbrace;false&rbrace;</code>\n so the grid keeps its declared widths and the sticky pinned columns work.\n </footer>\n</section>\n\n<style>\n .pin-foot { color: var(--sg-muted, #64748b); }\n\n /* Punch up the visual contrast for pinned columns so the user can\n immediately see which columns are frozen. The grid already supports\n these tokens; this demo just turns the dial higher to demo the\n feature. */\n .pinning-host {\n /* Body cells: a stronger accent-tinted background than the default\n 8% mix - the pinned strip should read as a clearly different surface.\n The two color-mix percentages MUST sum to 100% - otherwise CSS scales\n the result's alpha down (e.g. 55% + 18% = 73% -> 0.73 alpha), which\n makes the pinned column semi-transparent and lets the scrolling\n middle columns bleed through. */\n --sg-pinned-bg: color-mix(in oklab, var(--sg-bg, #ffffff) 82%, var(--sg-accent, #2563eb) 18%);\n /* Header cells: even bolder so the frozen header is unmistakable. */\n --sg-pinned-header-bg: color-mix(in oklab, var(--sg-header-bg, #f1f5f9) 70%, var(--sg-accent, #2563eb) 30%);\n /* Divider line on the inside edge - solid accent so it reads as\n intentional, not as a normal cell border. */\n --sg-pinned-divider: var(--sg-accent, #2563eb);\n }\n :global(html[data-theme='dark']) .pinning-host {\n --sg-pinned-bg: color-mix(in oklab, var(--sg-bg, #181d27) 76%, var(--sg-accent, #3b82f6) 24%);\n --sg-pinned-header-bg: color-mix(in oklab, var(--sg-header-bg, #1e2433) 64%, var(--sg-accent, #3b82f6) 36%);\n --sg-pinned-divider: var(--sg-accent, #3b82f6);\n }\n\n /* No icon - the accent-tinted background tokens above (--sg-pinned-bg\n / --sg-pinned-header-bg) carry the \"this column is frozen\" signal\n on their own. Punch the header tint further to make it unmistakable\n even at a glance. */\n .pinning-host {\n --sg-pinned-header-bg: color-mix(in oklab, var(--sg-header-bg, #f1f5f9) 58%, var(--sg-accent, #2563eb) 42%);\n }\n :global(html[data-theme='dark']) .pinning-host {\n --sg-pinned-header-bg: color-mix(in oklab, var(--sg-header-bg, #1e2433) 52%, var(--sg-accent, #3b82f6) 48%);\n }\n /* Make the pinned header text white on the now-strong accent fill so\n contrast stays readable in both modes. */\n .pinning-host :global(.sv-grid-head .sv-grid-column[data-pinned]) {\n color: #fff;\n }\n .pinning-host :global(.sv-grid-head .sv-grid-column[data-pinned] *) {\n color: inherit;\n }\n</style>\n"
1024
+ "source": "<!-- Documented in: docs/help/recipes.md -->\n<script lang=\"ts\">\n /**\n * 25. Column pinning + freezing\n * -----------------------------\n * Wide grid (8 generous columns ~2050px total) so horizontal scrolling\n * kicks in on any reasonable viewport. Company is pre-pinned to the\n * LEFT and Unit price to the RIGHT - they stay sticky while the middle\n * columns slide under them when you scroll.\n *\n * Try it:\n * - Scroll the grid horizontally - Company stays anchored on the left,\n * Unit price on the right, the rest scroll between them.\n * - Hover any middle-column header → click the ⋮ that appears →\n * \"Pin to left\" / \"Pin to right\" / \"Unpin column\".\n *\n * Pinning works with column virtualization on (the default): the pinned\n * columns render on their own either side of the virtual window, so only\n * the columns in view plus the pinned ones are in the DOM.\n *\n * A programmatic `api.setColumnPinning(id, side)` is on the v1.x\n * roadmap; today the `initialColumnPinning` prop covers the on-mount\n * case and the column menu covers user-driven re-pinning.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n type GridColumns,\n } from '@svgrid/grid'\n import { makeOrders, type Order } from '../shared/seed'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n })\n\n let rows = $state<Order[]>(makeOrders(500))\n\n // 8 generous columns - total ~2050px so the grid overflows horizontally\n // on any normal monitor. Pinning is only meaningful when the grid\n // actually scrolls; the demo has to force that condition to demonstrate\n // the feature.\n const columns: GridColumns<Order> = [\n { field: 'company', header: 'Company', width: 240 },\n { field: 'product', header: 'Product', width: 320 },\n { field: 'orderId', header: 'Order ID', width: 180 },\n { field: 'country', header: 'Country', width: 200 },\n { field: 'sellDate', header: 'Sell date', width: 220,\n format: { type: 'date', pattern: 'y-m-d' } },\n { field: 'quantity', header: 'Quantity', width: 170,\n format: { type: 'number', options: { maximumFractionDigits: 0 } } },\n { field: 'inStock', header: 'In stock', width: 160 },\n { field: 'price', header: 'Unit price', width: 200,\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <div\n class=\"shrink-0 rounded-lg border px-4 py-3\"\n style=\"border-color: var(--sg-border); background: var(--sg-header-bg);\"\n >\n <p class=\"text-sm font-semibold\" style=\"color: var(--sg-fg);\">\n Company is pinned LEFT · Unit price is pinned RIGHT\n </p>\n <ol class=\"mt-1 text-xs list-decimal pl-5 space-y-0.5\" style=\"color: var(--sg-fg);\">\n <li>\n <strong>Scroll horizontally</strong> - Company and Unit price stay anchored at the edges\n while everything else slides between them.\n </li>\n <li>\n <strong>Pin another column</strong> - hover any middle header → click the\n <code style=\"color: var(--site-accent-2, #22d3ee);\">⋮</code> → pick\n <em>Pin to left</em>, <em>Pin to right</em>, or <em>Unpin column</em>.\n </li>\n </ol>\n </div>\n\n <div class=\"flex-1 min-h-0 pinning-host\">\n <SvGrid responsive={true}\n columnResize\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n showRowNumbers={true}\n showPagination={true}\n pageSize={50}\n enableCellSelection={true}\n rowHeight={36}\n containerHeight=\"100%\"\n fitColumns={false}\n initialColumnPinning={{ left: ['company'], right: ['price'] }}\n />\n </div>\n\n <footer class=\"pin-foot text-xs shrink-0\">\n {rows.length} rows · {columns.length} columns ·\n <code>fitColumns=&lbrace;false&rbrace;</code> +\n <code>columnVirtualization=&lbrace;false&rbrace;</code>\n so the grid keeps its declared widths and the sticky pinned columns work.\n </footer>\n</section>\n\n<style>\n .pin-foot { color: var(--sg-muted, #64748b); }\n\n /* Punch up the visual contrast for pinned columns so the user can\n immediately see which columns are frozen. The grid already supports\n these tokens; this demo just turns the dial higher to demo the\n feature. */\n .pinning-host {\n /* Body cells: a stronger accent-tinted background than the default\n 8% mix - the pinned strip should read as a clearly different surface.\n The two color-mix percentages MUST sum to 100% - otherwise CSS scales\n the result's alpha down (e.g. 55% + 18% = 73% -> 0.73 alpha), which\n makes the pinned column semi-transparent and lets the scrolling\n middle columns bleed through. */\n --sg-pinned-bg: color-mix(in oklab, var(--sg-bg, #ffffff) 82%, var(--sg-accent, #2563eb) 18%);\n /* Header cells: even bolder so the frozen header is unmistakable. */\n --sg-pinned-header-bg: color-mix(in oklab, var(--sg-header-bg, #f1f5f9) 70%, var(--sg-accent, #2563eb) 30%);\n /* Divider line on the inside edge - solid accent so it reads as\n intentional, not as a normal cell border. */\n --sg-pinned-divider: var(--sg-accent, #2563eb);\n }\n :global(html[data-theme='dark']) .pinning-host {\n --sg-pinned-bg: color-mix(in oklab, var(--sg-bg, #181d27) 76%, var(--sg-accent, #3b82f6) 24%);\n --sg-pinned-header-bg: color-mix(in oklab, var(--sg-header-bg, #1e2433) 64%, var(--sg-accent, #3b82f6) 36%);\n --sg-pinned-divider: var(--sg-accent, #3b82f6);\n }\n\n /* No icon - the accent-tinted background tokens above (--sg-pinned-bg\n / --sg-pinned-header-bg) carry the \"this column is frozen\" signal\n on their own. Punch the header tint further to make it unmistakable\n even at a glance. */\n .pinning-host {\n --sg-pinned-header-bg: color-mix(in oklab, var(--sg-header-bg, #f1f5f9) 58%, var(--sg-accent, #2563eb) 42%);\n }\n :global(html[data-theme='dark']) .pinning-host {\n --sg-pinned-header-bg: color-mix(in oklab, var(--sg-header-bg, #1e2433) 52%, var(--sg-accent, #3b82f6) 48%);\n }\n /* Make the pinned header text white on the now-strong accent fill so\n contrast stays readable in both modes. */\n .pinning-host :global(.sv-grid-head .sv-grid-column[data-pinned]) {\n color: #fff;\n }\n .pinning-host :global(.sv-grid-head .sv-grid-column[data-pinned] *) {\n color: inherit;\n }\n</style>\n"
1025
1025
  },
1026
1026
  {
1027
1027
  "id": "250-calendar",
@@ -2365,7 +2365,7 @@ export const examples = [
2365
2365
  "title": "Healthcare Emr",
2366
2366
  "category": "Industry Templates",
2367
2367
  "blurb": "41. Healthcare EMR - inpatient board An ICU / inpatient list as it would appear to a clinician. Built to show the procurement team's healthcare buyer two things at",
2368
- "source": "<!-- Documented in: docs/help/recipes.md -->\n<script lang=\"ts\">\n /**\n * 41. Healthcare EMR - inpatient board\n * ------------------------------------\n * An ICU / inpatient list as it would appear to a clinician. Built\n * to show the procurement team's healthcare buyer two things at\n * once:\n *\n * 1. **The grid renders rich clinical cells.** Vitals sparkline,\n * color-graded risk score, allergy chip cluster, code-status\n * pill, primary-nurse avatar - all in stock SvGrid cell\n * snippets, no plugin layer.\n *\n * 2. **Role-based access actually works.** The role switcher\n * drives the cell-level `editable: (ctx) => boolean` predicate\n * the grid added in demo 35. Nurses can edit notes; physicians\n * can move code status and orders; admins also see discharge\n * planning fields. Try Viewer to see everything lock.\n *\n * No PHI: every name, MRN, allergy, and diagnosis is synthetic\n * and deterministically seeded. The structure is what counts.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n renderSnippet,\n type ColumnDef,\n type CellContext,\n } from '@svgrid/grid'\n\n // ---- Domain ---------------------------------------------------------\n\n type CodeStatus = 'full_code' | 'dnr' | 'dni' | 'comfort_only'\n type Risk = 'low' | 'moderate' | 'high' | 'critical'\n type Role = 'viewer' | 'nurse' | 'physician' | 'admin'\n\n type Allergy = string\n type Patient = {\n mrn: string\n initials: string\n age: number\n sex: 'F' | 'M'\n room: string\n bed: string\n los: number\n diagnosis: string\n risk: Risk\n riskScore: number // 0–100\n codeStatus: CodeStatus\n allergies: Allergy[]\n attending: string\n primaryNurse: string\n notes: string\n /** 30-point trailing vitals (heart rate). */\n hrTrend: number[]\n /** Latest values for the trend tile. */\n hr: number\n spo2: number\n sbp: number // systolic BP\n }\n\n // ---- Seed -----------------------------------------------------------\n\n const FIRST_INITIALS = ['A.M.', 'J.K.', 'R.S.', 'M.O.', 'L.P.', 'D.B.', 'C.T.', 'N.E.', 'S.H.', 'K.Y.']\n const DIAGNOSES = [\n 'Sepsis, unspecified', 'ARDS w/ mechanical vent', 'Post-op cabg',\n 'Septic shock', 'STEMI, anterior', 'Acute pancreatitis',\n 'Cerebrovascular accident', 'Pneumonia, MRSA', 'GI bleed, lower',\n 'DKA, T1DM', 'Pulmonary embolism', 'Renal failure, AKI',\n ]\n const ALLERGY_POOL = ['Penicillin', 'Latex', 'Sulfa', 'Iodine', 'Codeine', 'Aspirin', 'NSAIDs', 'Shellfish', 'Eggs']\n const ATTENDINGS = ['Dr. Park', 'Dr. Singh', 'Dr. Cohen', 'Dr. Nakamura', 'Dr. Mehta', 'Dr. Diaz']\n const NURSES = ['Tran', 'Olsen', 'Wu', 'Khan', 'Diaz', 'Park', 'Nair', 'Silva']\n const CODE_STATUSES: readonly CodeStatus[] = ['full_code', 'dnr', 'dni', 'comfort_only']\n\n let prng = 0xC0CA0DE5\n function rnd(): number {\n prng = (prng * 1664525 + 1013904223) >>> 0\n return prng / 0xFFFFFFFF\n }\n function pick<T>(arr: readonly T[]): T { return arr[Math.floor(rnd() * arr.length)]! }\n\n function seedPatient(i: number): Patient {\n const initials = pick(FIRST_INITIALS)\n const age = 18 + Math.floor(rnd() * 80)\n const sex: 'F' | 'M' = rnd() < 0.5 ? 'F' : 'M'\n const riskScore = Math.floor(rnd() * 100)\n const risk: Risk =\n riskScore >= 80 ? 'critical' :\n riskScore >= 60 ? 'high' :\n riskScore >= 30 ? 'moderate' : 'low'\n const baseHr = 60 + Math.floor(rnd() * 70)\n const hrTrend: number[] = []\n let hr = baseHr\n for (let k = 0; k < 30; k += 1) {\n hr = Math.max(45, Math.min(160, hr + (rnd() - 0.5) * 8))\n hrTrend.push(Math.round(hr))\n }\n const allergyCount = Math.floor(rnd() * 3)\n const allergies: Allergy[] = []\n while (allergies.length < allergyCount) {\n const a = pick(ALLERGY_POOL)\n if (!allergies.includes(a)) allergies.push(a)\n }\n return {\n mrn: `M${(100_000 + i).toString().slice(-6)}`,\n initials,\n age, sex,\n room: `${pick(['SI', 'CC', 'MS'])}-${100 + Math.floor(rnd() * 30)}`,\n bed: pick(['A', 'B']),\n los: 1 + Math.floor(rnd() * 14),\n diagnosis: pick(DIAGNOSES),\n risk,\n riskScore,\n codeStatus: pick(CODE_STATUSES),\n allergies,\n attending: pick(ATTENDINGS),\n primaryNurse: pick(NURSES),\n notes: '',\n hrTrend,\n hr: hrTrend[hrTrend.length - 1]!,\n spo2: 88 + Math.floor(rnd() * 11),\n sbp: 90 + Math.floor(rnd() * 60),\n }\n }\n function seedRoster(n: number): Patient[] {\n const out: Patient[] = []\n for (let i = 0; i < n; i += 1) out.push(seedPatient(i))\n return out\n }\n\n // ---- State ---------------------------------------------------------\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n\n let patients = $state<Patient[]>(seedRoster(28))\n let role = $state<Role>('physician')\n\n // ---- Role permission rules -----------------------------------------\n\n const NURSE_EDITABLE = new Set(['notes', 'primaryNurse'])\n const PHYS_EDITABLE = new Set(['notes', 'primaryNurse', 'attending', 'codeStatus', 'diagnosis', 'allergies'])\n const ADMIN_EXTRA = new Set(['room', 'bed', 'los'])\n function canEditColumn(columnId: string): boolean {\n if (role === 'viewer') return false\n if (role === 'admin') return PHYS_EDITABLE.has(columnId) || ADMIN_EXTRA.has(columnId)\n if (role === 'physician') return PHYS_EDITABLE.has(columnId)\n return NURSE_EDITABLE.has(columnId)\n }\n function canSeeColumn(columnId: string): boolean {\n if (columnId === 'discharge') return role === 'admin'\n return true\n }\n\n function onCellValueChange(args: { rowIndex: number; columnId: string; newValue: unknown }): void {\n const patient = patients[args.rowIndex]\n if (!patient) return\n if (!canEditColumn(args.columnId)) return\n const next = patients.slice()\n next[args.rowIndex] = { ...patient, [args.columnId]: args.newValue } as Patient\n patients = next\n }\n\n // ---- KPI derivations ----------------------------------------------\n\n const kpis = $derived.by(() => {\n const total = patients.length\n const critical = patients.filter((p) => p.risk === 'critical').length\n const high = patients.filter((p) => p.risk === 'high').length\n const dnr = patients.filter((p) => p.codeStatus === 'dnr').length\n const avgRisk = total ? Math.round(patients.reduce((s, p) => s + p.riskScore, 0) / total) : 0\n const avgLos = total ? +(patients.reduce((s, p) => s + p.los, 0) / total).toFixed(1) : 0\n return { total, critical, high, dnr, avgRisk, avgLos }\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 let d = ''\n for (let i = 0; i < history.length; i += 1) {\n const x = Math.round(i * step * 100) / 100\n const y = Math.round((h - ((history[i]! - min) / range) * h) * 100) / 100\n d += `${i === 0 ? 'M' : 'L'}${x} ${y} `\n }\n return d.trim()\n }\n</script>\n\n<!-- ───────────────────── CELL SNIPPETS ───────────────────── -->\n\n{#snippet MrnCell(props: { row: Patient })}\n <span class=\"hc-mrn-wrap\">\n <span class=\"mono\">{props.row.mrn}</span>\n <span class=\"hc-mrn-init\">{props.row.initials}</span>\n </span>\n{/snippet}\n\n{#snippet AgeSexCell(props: { row: Patient })}\n <span class=\"hc-age\">{props.row.age}<span class=\"hc-sex\">{props.row.sex}</span></span>\n{/snippet}\n\n{#snippet LocationCell(props: { row: Patient })}\n <span class=\"hc-location\">\n <span class=\"hc-room\">{props.row.room}</span>\n <span class=\"hc-bed\">{props.row.bed}</span>\n </span>\n{/snippet}\n\n{#snippet RiskCell(props: { row: Patient })}\n <span class={`hc-risk hc-risk-${props.row.risk}`}>\n <span class=\"hc-risk-bar\">\n <span class=\"hc-risk-fill\" style={`width: ${props.row.riskScore}%`}></span>\n </span>\n <span class=\"hc-risk-num tabular-nums\">{props.row.riskScore}</span>\n </span>\n{/snippet}\n\n{#snippet CodeStatusCell(props: { row: Patient })}\n <span class={`hc-code hc-code-${props.row.codeStatus}`}>\n {props.row.codeStatus.replace(/_/g, ' ').toUpperCase()}\n </span>\n{/snippet}\n\n{#snippet VitalsCell(props: { row: Patient })}\n {@const pathD = sparkPath(props.row.hrTrend, 90, 24)}\n <span class=\"hc-vitals\">\n <svg class=\"hc-spark\" viewBox=\"0 0 90 24\" preserveAspectRatio=\"none\" aria-hidden=\"true\">\n <path d={pathD} fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\" />\n </svg>\n <span class=\"hc-vital-num tabular-nums\">HR {props.row.hr}</span>\n <span class=\"hc-vital-sub\">SpO₂ {props.row.spo2}% · BP {props.row.sbp}</span>\n </span>\n{/snippet}\n\n{#snippet AllergiesCell(props: { row: Patient })}\n {#if props.row.allergies.length === 0}\n <span class=\"hc-no-allergies\">NKDA</span>\n {:else}\n <span class=\"hc-allergy-list\">\n {#each props.row.allergies as a (a)}<span class=\"hc-allergy\">{a}</span>{/each}\n </span>\n {/if}\n{/snippet}\n\n{#snippet PlainCell(props: { row: Patient; field: keyof Patient })}\n <span>{props.row[props.field]}</span>\n{/snippet}\n\n<!-- ───────────────────── LAYOUT ───────────────────── -->\n\n<section class=\"hc-shell flex flex-col flex-1 min-h-0 gap-3\">\n <!-- KPI strip -->\n <div class=\"hc-kpi-strip\">\n <div class=\"hc-kpi\">\n <div class=\"hc-kpi-label\">Census</div>\n <div class=\"hc-kpi-value tabular-nums\">{kpis.total}</div>\n <div class=\"hc-kpi-foot\">patients on the unit</div>\n </div>\n <div class=\"hc-kpi\">\n <div class=\"hc-kpi-label\">Critical</div>\n <div class=\"hc-kpi-value tabular-nums hc-down\">{kpis.critical}</div>\n <div class=\"hc-kpi-foot\">{kpis.high} high · LOS avg {kpis.avgLos}d</div>\n </div>\n <div class=\"hc-kpi\">\n <div class=\"hc-kpi-label\">DNR</div>\n <div class=\"hc-kpi-value tabular-nums\">{kpis.dnr}</div>\n <div class=\"hc-kpi-foot\">code-status reviews queued</div>\n </div>\n <div class=\"hc-kpi\">\n <div class=\"hc-kpi-label\">Avg risk score</div>\n <div class={`hc-kpi-value tabular-nums ${kpis.avgRisk >= 60 ? 'hc-down' : ''}`}>{kpis.avgRisk}</div>\n <div class=\"hc-kpi-foot\">0–100, weighted</div>\n </div>\n <div class=\"hc-kpi\">\n <div class=\"hc-kpi-label\">Role</div>\n <div class={`hc-kpi-value hc-role hc-role-${role}`}>{role}</div>\n <div class=\"hc-kpi-foot\">switch below to compare</div>\n </div>\n </div>\n\n <!-- Toolbar -->\n <div class=\"hc-toolbar\">\n <span class=\"hc-role-row\">\n <span class=\"hc-throttle-label\">Acting as</span>\n {#each [{id: 'viewer', label: 'Viewer'}, {id: 'nurse', label: 'Nurse'}, {id: 'physician', label: 'Physician'}, {id: 'admin', label: 'Admin'}] as r (r.id)}\n <button\n type=\"button\"\n class={`hc-role-btn hc-role-${r.id}`}\n class:hc-role-active={role === r.id}\n onclick={() => (role = r.id as Role)}\n >{r.label}</button>\n {/each}\n </span>\n <div class=\"hc-toolbar-spacer\"></div>\n <span class=\"hc-hint\">Double-click a cell to edit. The role decides which cells unlock.</span>\n </div>\n\n <!-- Grid -->\n <div class=\"flex-1 min-h-0\">\n <SvGrid responsive={true}\n columnResize\n data={patients}\n columns={[\n { field: 'mrn', header: 'MRN / pt.', width: 150, editable: false,\n cell: (ctx) => renderSnippet(MrnCell, { row: ctx.row.original }) },\n { field: 'age', header: 'Age', editorType: 'number', width: 80, editable: false,\n cell: (ctx) => renderSnippet(AgeSexCell, { row: ctx.row.original }) },\n { field: 'room', header: 'Location', width: 110,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('room'),\n cell: (ctx) => renderSnippet(LocationCell, { row: ctx.row.original }) },\n { field: 'diagnosis', header: 'Diagnosis', width: 220,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('diagnosis'),\n cell: (ctx) => renderSnippet(PlainCell, { row: ctx.row.original, field: 'diagnosis' }) },\n { field: 'riskScore', header: 'Risk', editorType: 'number', width: 160, editable: false,\n cell: (ctx) => renderSnippet(RiskCell, { row: ctx.row.original }) },\n { field: 'codeStatus', header: 'Code', width: 130,\n editorType: 'list',\n editorOptions: CODE_STATUSES as unknown as ReadonlyArray<string>,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('codeStatus'),\n cell: (ctx) => renderSnippet(CodeStatusCell, { row: ctx.row.original }) },\n { field: 'hr', header: 'Vitals (last 30 hr)', editorType: 'number', width: 200, editable: false,\n cell: (ctx) => renderSnippet(VitalsCell, { row: ctx.row.original }) },\n { field: 'allergies', header: 'Allergies', width: 200,\n editorType: 'chips',\n editorOptions: ALLERGY_POOL as unknown as ReadonlyArray<string>,\n editorMultiple: true,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('allergies'),\n cell: (ctx) => renderSnippet(AllergiesCell, { row: ctx.row.original }) },\n { field: 'attending', header: 'Attending', width: 140,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('attending'),\n cell: (ctx) => renderSnippet(PlainCell, { row: ctx.row.original, field: 'attending' }) },\n { field: 'primaryNurse', header: 'Primary RN', width: 110,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('primaryNurse'),\n cell: (ctx) => renderSnippet(PlainCell, { row: ctx.row.original, field: 'primaryNurse' }) },\n { field: 'los', header: 'LOS', editorType: 'number', width: 70,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('los'),\n cell: (ctx) => renderSnippet(PlainCell, { row: ctx.row.original, field: 'los' }) },\n { field: 'notes', header: 'Notes', editorType: 'text', width: 200,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('notes') },\n ] satisfies ColumnDef<typeof features, Patient>[]}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"cell\"\n enableInlineEditing={role !== 'viewer'}\n enableCellSelection={true}\n rowHeight={48}\n containerHeight=\"100%\"\n fitColumns={false}\n onCellValueChange={onCellValueChange}\n />\n </div>\n</section>\n\n<style>\n /* ─── KPI strip ──────────────────────────────────────────────── */\n .hc-kpi-strip {\n display: grid;\n grid-template-columns: repeat(5, minmax(0, 1fr));\n gap: 10px;\n flex-shrink: 0;\n }\n .hc-kpi {\n border: 1px solid var(--sg-border, #e2e8f0);\n background: var(--sg-bg, #ffffff);\n border-radius: 10px;\n padding: 12px 14px;\n }\n .hc-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 .hc-kpi-value {\n font-size: 22px;\n font-weight: 700;\n line-height: 1.1;\n letter-spacing: -0.01em;\n text-transform: capitalize;\n }\n .hc-kpi-foot {\n margin-top: 6px;\n font-size: 11px;\n color: var(--sg-muted, #64748b);\n }\n .hc-down { color: #dc2626; }\n :global([data-theme='dark']) .hc-down { color: #f87171; }\n .hc-role {\n font-size: 16px;\n }\n\n /* ─── Toolbar ────────────────────────────────────────────────── */\n .hc-toolbar {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 8px;\n flex-shrink: 0;\n }\n .hc-toolbar-spacer { flex: 1 1 auto; }\n .hc-role-row { display: inline-flex; flex-wrap: wrap; gap: 6px; align-items: center; }\n .hc-throttle-label {\n font-size: 11.5px;\n color: var(--sg-muted, #64748b);\n margin-right: 4px;\n }\n .hc-role-btn {\n padding: 4px 12px;\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 font-size: 12px;\n cursor: pointer;\n }\n .hc-role-btn:hover { background: var(--sg-header-bg, #f1f5f9); }\n .hc-role-active {\n border-color: transparent;\n box-shadow: 0 0 0 2px var(--sg-accent, #2563eb) inset;\n font-weight: 600;\n }\n .hc-role-viewer { color: var(--sg-muted, #475569); }\n .hc-role-nurse { color: #166534; }\n .hc-role-physician { color: #1d4ed8; }\n .hc-role-admin { color: #b91c1c; }\n :global([data-theme='dark']) .hc-role-nurse { color: #4ade80; }\n :global([data-theme='dark']) .hc-role-physician { color: #93c5fd; }\n :global([data-theme='dark']) .hc-role-admin { color: #f87171; }\n .hc-hint { font-size: 11.5px; color: var(--sg-muted, #64748b); }\n\n /* ─── Cells ──────────────────────────────────────────────────── */\n :global(.mono) {\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: 12px;\n letter-spacing: 0.02em;\n }\n\n :global(.hc-mrn-wrap) {\n display: inline-flex;\n flex-direction: column;\n gap: 1px;\n }\n :global(.hc-mrn-init) {\n font-size: 10.5px;\n color: var(--sg-muted, #64748b);\n letter-spacing: 0.05em;\n }\n\n :global(.hc-age) {\n font-variant-numeric: tabular-nums;\n font-weight: 600;\n }\n :global(.hc-sex) {\n font-size: 10px;\n color: var(--sg-muted, #64748b);\n margin-left: 3px;\n }\n\n :global(.hc-location) {\n display: inline-flex;\n align-items: baseline;\n gap: 3px;\n }\n :global(.hc-room) { font-weight: 600; font-family: ui-monospace, Menlo, monospace; }\n :global(.hc-bed) { font-size: 11px; color: var(--sg-muted, #64748b); }\n\n :global(.hc-risk) {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n }\n :global(.hc-risk-bar) {\n position: relative;\n width: 70px;\n height: 6px;\n background: var(--sg-border, #e2e8f0);\n border-radius: 3px;\n overflow: hidden;\n }\n :global(.hc-risk-fill) {\n position: absolute; inset: 0 auto 0 0;\n background: currentColor;\n }\n :global(.hc-risk-num) {\n font-weight: 700;\n min-width: 26px;\n }\n :global(.hc-risk-low) { color: #16a34a; }\n :global(.hc-risk-moderate) { color: #ca8a04; }\n :global(.hc-risk-high) { color: #ea580c; }\n :global(.hc-risk-critical) { color: #dc2626; }\n\n :global(.hc-code) {\n display: inline-block;\n padding: 2px 8px;\n border-radius: 4px;\n font-size: 10.5px;\n font-weight: 700;\n letter-spacing: 0.04em;\n }\n :global(.hc-code-full_code) { background: #dcfce7; color: #166534; }\n :global(.hc-code-dnr) { background: #fee2e2; color: #b91c1c; }\n :global(.hc-code-dni) { background: #fef3c7; color: #92400e; }\n :global(.hc-code-comfort_only) { background: var(--sg-bg-subtle, var(--sg-header-bg, #e2e8f0)); color: var(--sg-muted, #475569); }\n :global([data-theme='dark'] .hc-code-full_code) { background: rgba(34,197,94,.18); color: #4ade80; }\n :global([data-theme='dark'] .hc-code-dnr) { background: rgba(239,68,68,.18); color: #f87171; }\n :global([data-theme='dark'] .hc-code-dni) { background: rgba(245,158,11,.18); color: #fbbf24; }\n\n :global(.hc-vitals) {\n display: inline-flex;\n flex-direction: column;\n gap: 1px;\n color: #2563eb;\n line-height: 1.2;\n }\n :global([data-theme='dark'] .hc-vitals) { color: #60a5fa; }\n :global(.hc-spark) { width: 90px; height: 24px; }\n :global(.hc-vital-num) {\n color: var(--sg-fg, #1e293b);\n font-weight: 600;\n font-size: 12px;\n }\n :global(.hc-vital-sub) {\n color: var(--sg-muted, #64748b);\n font-size: 10.5px;\n }\n\n :global(.hc-no-allergies) {\n color: var(--sg-muted, #64748b);\n font-size: 11px;\n font-style: italic;\n }\n :global(.hc-allergy-list) {\n display: inline-flex;\n flex-wrap: wrap;\n gap: 3px;\n }\n :global(.hc-allergy) {\n background: #fee2e2;\n color: #b91c1c;\n padding: 1px 6px;\n border-radius: 999px;\n font-size: 10.5px;\n font-weight: 600;\n }\n :global([data-theme='dark'] .hc-allergy) { background: rgba(239,68,68,.18); color: #f87171; }\n</style>\n"
2368
+ "source": "<!-- Documented in: docs/help/recipes.md -->\n<script lang=\"ts\">\n /**\n * 41. Healthcare EMR - inpatient board\n * ------------------------------------\n * An ICU / inpatient list as it would appear to a clinician. Built\n * to show the procurement team's healthcare buyer two things at\n * once:\n *\n * 1. **The grid renders rich clinical cells.** Vitals sparkline,\n * color-graded risk score, allergy chip cluster, code-status\n * pill, primary-nurse avatar - all in stock SvGrid cell\n * snippets, no plugin layer.\n *\n * 2. **Role-based access actually works.** The role switcher\n * drives the cell-level `editable: (ctx) => boolean` predicate\n * the grid added in demo 35. Nurses can edit notes; physicians\n * can move code status and orders; admins also see discharge\n * planning fields. Try Viewer to see everything lock.\n *\n * No PHI: every name, MRN, allergy, and diagnosis is synthetic\n * and deterministically seeded. The structure is what counts.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n renderSnippet,\n type ColumnDef,\n type CellContext,\n } from '@svgrid/grid'\n\n // ---- Domain ---------------------------------------------------------\n\n type CodeStatus = 'full_code' | 'dnr' | 'dni' | 'comfort_only'\n type Risk = 'low' | 'moderate' | 'high' | 'critical'\n type Role = 'viewer' | 'nurse' | 'physician' | 'admin'\n\n type Allergy = string\n type Patient = {\n mrn: string\n initials: string\n age: number\n sex: 'F' | 'M'\n room: string\n bed: string\n los: number\n diagnosis: string\n risk: Risk\n riskScore: number // 0–100\n codeStatus: CodeStatus\n allergies: Allergy[]\n attending: string\n primaryNurse: string\n notes: string\n /** 30-point trailing vitals (heart rate). */\n hrTrend: number[]\n /** Latest values for the trend tile. */\n hr: number\n spo2: number\n sbp: number // systolic BP\n }\n\n // ---- Seed -----------------------------------------------------------\n\n const FIRST_INITIALS = ['A.M.', 'J.K.', 'R.S.', 'M.O.', 'L.P.', 'D.B.', 'C.T.', 'N.E.', 'S.H.', 'K.Y.']\n const DIAGNOSES = [\n 'Sepsis, unspecified', 'ARDS w/ mechanical vent', 'Post-op cabg',\n 'Septic shock', 'STEMI, anterior', 'Acute pancreatitis',\n 'Cerebrovascular accident', 'Pneumonia, MRSA', 'GI bleed, lower',\n 'DKA, T1DM', 'Pulmonary embolism', 'Renal failure, AKI',\n ]\n const ALLERGY_POOL = ['Penicillin', 'Latex', 'Sulfa', 'Iodine', 'Codeine', 'Aspirin', 'NSAIDs', 'Shellfish', 'Eggs']\n const ATTENDINGS = ['Dr. Park', 'Dr. Singh', 'Dr. Cohen', 'Dr. Nakamura', 'Dr. Mehta', 'Dr. Diaz']\n const NURSES = ['Tran', 'Olsen', 'Wu', 'Khan', 'Diaz', 'Park', 'Nair', 'Silva']\n const CODE_STATUSES: readonly CodeStatus[] = ['full_code', 'dnr', 'dni', 'comfort_only']\n\n let prng = 0xC0CA0DE5\n function rnd(): number {\n prng = (prng * 1664525 + 1013904223) >>> 0\n return prng / 0xFFFFFFFF\n }\n function pick<T>(arr: readonly T[]): T { return arr[Math.floor(rnd() * arr.length)]! }\n\n function seedPatient(i: number): Patient {\n const initials = pick(FIRST_INITIALS)\n const age = 18 + Math.floor(rnd() * 80)\n const sex: 'F' | 'M' = rnd() < 0.5 ? 'F' : 'M'\n const riskScore = Math.floor(rnd() * 100)\n const risk: Risk =\n riskScore >= 80 ? 'critical' :\n riskScore >= 60 ? 'high' :\n riskScore >= 30 ? 'moderate' : 'low'\n const baseHr = 60 + Math.floor(rnd() * 70)\n const hrTrend: number[] = []\n let hr = baseHr\n for (let k = 0; k < 30; k += 1) {\n hr = Math.max(45, Math.min(160, hr + (rnd() - 0.5) * 8))\n hrTrend.push(Math.round(hr))\n }\n const allergyCount = Math.floor(rnd() * 3)\n const allergies: Allergy[] = []\n while (allergies.length < allergyCount) {\n const a = pick(ALLERGY_POOL)\n if (!allergies.includes(a)) allergies.push(a)\n }\n return {\n mrn: `M${(100_000 + i).toString().slice(-6)}`,\n initials,\n age, sex,\n room: `${pick(['SI', 'CC', 'MS'])}-${100 + Math.floor(rnd() * 30)}`,\n bed: pick(['A', 'B']),\n los: 1 + Math.floor(rnd() * 14),\n diagnosis: pick(DIAGNOSES),\n risk,\n riskScore,\n codeStatus: pick(CODE_STATUSES),\n allergies,\n attending: pick(ATTENDINGS),\n primaryNurse: pick(NURSES),\n notes: '',\n hrTrend,\n hr: hrTrend[hrTrend.length - 1]!,\n spo2: 88 + Math.floor(rnd() * 11),\n sbp: 90 + Math.floor(rnd() * 60),\n }\n }\n function seedRoster(n: number): Patient[] {\n const out: Patient[] = []\n for (let i = 0; i < n; i += 1) out.push(seedPatient(i))\n return out\n }\n\n // ---- State ---------------------------------------------------------\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n\n let patients = $state<Patient[]>(seedRoster(28))\n let role = $state<Role>('physician')\n\n // ---- Role permission rules -----------------------------------------\n\n const NURSE_EDITABLE = new Set(['notes', 'primaryNurse'])\n const PHYS_EDITABLE = new Set(['notes', 'primaryNurse', 'attending', 'codeStatus', 'diagnosis', 'allergies'])\n const ADMIN_EXTRA = new Set(['room', 'bed', 'los'])\n function canEditColumn(columnId: string): boolean {\n if (role === 'viewer') return false\n if (role === 'admin') return PHYS_EDITABLE.has(columnId) || ADMIN_EXTRA.has(columnId)\n if (role === 'physician') return PHYS_EDITABLE.has(columnId)\n return NURSE_EDITABLE.has(columnId)\n }\n function canSeeColumn(columnId: string): boolean {\n if (columnId === 'discharge') return role === 'admin'\n return true\n }\n\n function onCellValueChange(args: { rowIndex: number; columnId: string; newValue: unknown }): void {\n const patient = patients[args.rowIndex]\n if (!patient) return\n if (!canEditColumn(args.columnId)) return\n const next = patients.slice()\n next[args.rowIndex] = { ...patient, [args.columnId]: args.newValue } as Patient\n patients = next\n }\n\n // ---- KPI derivations ----------------------------------------------\n\n const kpis = $derived.by(() => {\n const total = patients.length\n const critical = patients.filter((p) => p.risk === 'critical').length\n const high = patients.filter((p) => p.risk === 'high').length\n const dnr = patients.filter((p) => p.codeStatus === 'dnr').length\n const avgRisk = total ? Math.round(patients.reduce((s, p) => s + p.riskScore, 0) / total) : 0\n const avgLos = total ? +(patients.reduce((s, p) => s + p.los, 0) / total).toFixed(1) : 0\n return { total, critical, high, dnr, avgRisk, avgLos }\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 let d = ''\n for (let i = 0; i < history.length; i += 1) {\n const x = Math.round(i * step * 100) / 100\n const y = Math.round((h - ((history[i]! - min) / range) * h) * 100) / 100\n d += `${i === 0 ? 'M' : 'L'}${x} ${y} `\n }\n return d.trim()\n }\n</script>\n\n<!-- ───────────────────── CELL SNIPPETS ───────────────────── -->\n\n{#snippet MrnCell(props: { row: Patient })}\n <span class=\"hc-mrn-wrap\">\n <span class=\"mono\">{props.row.mrn}</span>\n <span class=\"hc-mrn-init\">{props.row.initials}</span>\n </span>\n{/snippet}\n\n{#snippet AgeSexCell(props: { row: Patient })}\n <span class=\"hc-age\">{props.row.age}<span class=\"hc-sex\">{props.row.sex}</span></span>\n{/snippet}\n\n{#snippet LocationCell(props: { row: Patient })}\n <span class=\"hc-location\">\n <span class=\"hc-room\">{props.row.room}</span>\n <span class=\"hc-bed\">{props.row.bed}</span>\n </span>\n{/snippet}\n\n{#snippet RiskCell(props: { row: Patient })}\n <span class={`hc-risk hc-risk-${props.row.risk}`}>\n <span class=\"hc-risk-bar\">\n <span class=\"hc-risk-fill\" style={`width: ${props.row.riskScore}%`}></span>\n </span>\n <span class=\"hc-risk-num tabular-nums\">{props.row.riskScore}</span>\n </span>\n{/snippet}\n\n{#snippet CodeStatusCell(props: { row: Patient })}\n <span class={`hc-code hc-code-${props.row.codeStatus}`}>\n {props.row.codeStatus.replace(/_/g, ' ').toUpperCase()}\n </span>\n{/snippet}\n\n{#snippet VitalsCell(props: { row: Patient })}\n {@const pathD = sparkPath(props.row.hrTrend, 90, 24)}\n <span class=\"hc-vitals\">\n <svg class=\"hc-spark\" viewBox=\"0 0 90 24\" preserveAspectRatio=\"none\" aria-hidden=\"true\">\n <path d={pathD} fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\" />\n </svg>\n <span class=\"hc-vital-num tabular-nums\">HR {props.row.hr}</span>\n <span class=\"hc-vital-sub\">SpO₂ {props.row.spo2}% · BP {props.row.sbp}</span>\n </span>\n{/snippet}\n\n{#snippet AllergiesCell(props: { row: Patient })}\n {#if props.row.allergies.length === 0}\n <span class=\"hc-no-allergies\">NKDA</span>\n {:else}\n <span class=\"hc-allergy-list\">\n {#each props.row.allergies as a (a)}<span class=\"hc-allergy\">{a}</span>{/each}\n </span>\n {/if}\n{/snippet}\n\n{#snippet PlainCell(props: { row: Patient; field: keyof Patient })}\n <span>{props.row[props.field]}</span>\n{/snippet}\n\n<!-- ───────────────────── LAYOUT ───────────────────── -->\n\n<section class=\"hc-shell flex flex-col flex-1 min-h-0 gap-3\">\n <!-- KPI strip -->\n <div class=\"hc-kpi-strip\">\n <div class=\"hc-kpi\">\n <div class=\"hc-kpi-label\">Census</div>\n <div class=\"hc-kpi-value tabular-nums\">{kpis.total}</div>\n <div class=\"hc-kpi-foot\">patients on the unit</div>\n </div>\n <div class=\"hc-kpi\">\n <div class=\"hc-kpi-label\">Critical</div>\n <div class=\"hc-kpi-value tabular-nums hc-down\">{kpis.critical}</div>\n <div class=\"hc-kpi-foot\">{kpis.high} high · LOS avg {kpis.avgLos}d</div>\n </div>\n <div class=\"hc-kpi\">\n <div class=\"hc-kpi-label\">DNR</div>\n <div class=\"hc-kpi-value tabular-nums\">{kpis.dnr}</div>\n <div class=\"hc-kpi-foot\">code-status reviews queued</div>\n </div>\n <div class=\"hc-kpi\">\n <div class=\"hc-kpi-label\">Avg risk score</div>\n <div class={`hc-kpi-value tabular-nums ${kpis.avgRisk >= 60 ? 'hc-down' : ''}`}>{kpis.avgRisk}</div>\n <div class=\"hc-kpi-foot\">0–100, weighted</div>\n </div>\n <div class=\"hc-kpi\">\n <div class=\"hc-kpi-label\">Role</div>\n <div class={`hc-kpi-value hc-role hc-role-${role}`}>{role}</div>\n <div class=\"hc-kpi-foot\">switch below to compare</div>\n </div>\n </div>\n\n <!-- Toolbar -->\n <div class=\"hc-toolbar\">\n <span class=\"hc-role-row\">\n <span class=\"hc-throttle-label\">Acting as</span>\n {#each [{id: 'viewer', label: 'Viewer'}, {id: 'nurse', label: 'Nurse'}, {id: 'physician', label: 'Physician'}, {id: 'admin', label: 'Admin'}] as r (r.id)}\n <button\n type=\"button\"\n class={`hc-role-btn hc-role-${r.id}`}\n class:hc-role-active={role === r.id}\n onclick={() => (role = r.id as Role)}\n >{r.label}</button>\n {/each}\n </span>\n <div class=\"hc-toolbar-spacer\"></div>\n <span class=\"hc-hint\">Double-click a cell to edit. The role decides which cells unlock.</span>\n </div>\n\n <!-- Grid -->\n <div class=\"flex-1 min-h-0\">\n <SvGrid responsive={true}\n columnResize\n data={patients}\n columns={[\n { field: 'mrn', header: 'MRN / pt.', width: 150, editable: false,\n cell: (ctx) => renderSnippet(MrnCell, { row: ctx.row.original }) },\n { field: 'age', header: 'Age', editorType: 'number', width: 80, editable: false,\n cell: (ctx) => renderSnippet(AgeSexCell, { row: ctx.row.original }) },\n { field: 'room', header: 'Location', width: 110,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('room'),\n cell: (ctx) => renderSnippet(LocationCell, { row: ctx.row.original }) },\n { field: 'diagnosis', header: 'Diagnosis', width: 220,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('diagnosis'),\n cell: (ctx) => renderSnippet(PlainCell, { row: ctx.row.original, field: 'diagnosis' }) },\n { field: 'riskScore', header: 'Risk', editorType: 'number', width: 160, editable: false,\n cell: (ctx) => renderSnippet(RiskCell, { row: ctx.row.original }) },\n { field: 'codeStatus', header: 'Code', width: 130,\n editorType: 'list',\n editorOptions: CODE_STATUSES as unknown as ReadonlyArray<string>,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('codeStatus'),\n cell: (ctx) => renderSnippet(CodeStatusCell, { row: ctx.row.original }) },\n { field: 'hr', header: 'Vitals (last 30 hr)', editorType: 'number', width: 200, editable: false,\n cell: (ctx) => renderSnippet(VitalsCell, { row: ctx.row.original }) },\n { field: 'allergies', header: 'Allergies', width: 200,\n editorType: 'chips',\n editorOptions: ALLERGY_POOL as unknown as ReadonlyArray<string>,\n editorMultiple: true,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('allergies'),\n cell: (ctx) => renderSnippet(AllergiesCell, { row: ctx.row.original }) },\n { field: 'attending', header: 'Attending', width: 140,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('attending'),\n cell: (ctx) => renderSnippet(PlainCell, { row: ctx.row.original, field: 'attending' }) },\n { field: 'primaryNurse', header: 'Primary RN', width: 110,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('primaryNurse'),\n cell: (ctx) => renderSnippet(PlainCell, { row: ctx.row.original, field: 'primaryNurse' }) },\n { field: 'los', header: 'LOS', editorType: 'number', width: 70,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('los'),\n cell: (ctx) => renderSnippet(PlainCell, { row: ctx.row.original, field: 'los' }) },\n { field: 'notes', header: 'Notes', editorType: 'text', width: 200,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('notes') },\n ] satisfies ColumnDef<typeof features, Patient>[]}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"cell\"\n enableInlineEditing={role !== 'viewer'}\n enableCellSelection={true}\n rowHeight={54}\n containerHeight=\"100%\"\n fitColumns={false}\n onCellValueChange={onCellValueChange}\n />\n </div>\n</section>\n\n<style>\n /* ─── KPI strip ──────────────────────────────────────────────── */\n .hc-kpi-strip {\n display: grid;\n grid-template-columns: repeat(5, minmax(0, 1fr));\n gap: 10px;\n flex-shrink: 0;\n }\n .hc-kpi {\n border: 1px solid var(--sg-border, #e2e8f0);\n background: var(--sg-bg, #ffffff);\n border-radius: 10px;\n padding: 12px 14px;\n }\n .hc-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 .hc-kpi-value {\n font-size: 22px;\n font-weight: 700;\n line-height: 1.1;\n letter-spacing: -0.01em;\n text-transform: capitalize;\n }\n .hc-kpi-foot {\n margin-top: 6px;\n font-size: 11px;\n color: var(--sg-muted, #64748b);\n }\n .hc-down { color: #dc2626; }\n :global([data-theme='dark']) .hc-down { color: #f87171; }\n .hc-role {\n font-size: 16px;\n }\n\n /* ─── Toolbar ────────────────────────────────────────────────── */\n .hc-toolbar {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 8px;\n flex-shrink: 0;\n }\n .hc-toolbar-spacer { flex: 1 1 auto; }\n .hc-role-row { display: inline-flex; flex-wrap: wrap; gap: 6px; align-items: center; }\n .hc-throttle-label {\n font-size: 11.5px;\n color: var(--sg-muted, #64748b);\n margin-right: 4px;\n }\n .hc-role-btn {\n padding: 4px 12px;\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 font-size: 12px;\n cursor: pointer;\n }\n .hc-role-btn:hover { background: var(--sg-header-bg, #f1f5f9); }\n .hc-role-active {\n border-color: transparent;\n box-shadow: 0 0 0 2px var(--sg-accent, #2563eb) inset;\n font-weight: 600;\n }\n .hc-role-viewer { color: var(--sg-muted, #475569); }\n .hc-role-nurse { color: #166534; }\n .hc-role-physician { color: #1d4ed8; }\n .hc-role-admin { color: #b91c1c; }\n :global([data-theme='dark']) .hc-role-nurse { color: #4ade80; }\n :global([data-theme='dark']) .hc-role-physician { color: #93c5fd; }\n :global([data-theme='dark']) .hc-role-admin { color: #f87171; }\n .hc-hint { font-size: 11.5px; color: var(--sg-muted, #64748b); }\n\n /* ─── Cells ──────────────────────────────────────────────────── */\n :global(.mono) {\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: 12px;\n letter-spacing: 0.02em;\n }\n\n :global(.hc-mrn-wrap) {\n display: inline-flex;\n flex-direction: column;\n gap: 1px;\n }\n :global(.hc-mrn-init) {\n font-size: 10.5px;\n color: var(--sg-muted, #64748b);\n letter-spacing: 0.05em;\n }\n\n :global(.hc-age) {\n font-variant-numeric: tabular-nums;\n font-weight: 600;\n }\n :global(.hc-sex) {\n font-size: 10px;\n color: var(--sg-muted, #64748b);\n margin-left: 3px;\n }\n\n :global(.hc-location) {\n display: inline-flex;\n align-items: baseline;\n gap: 3px;\n }\n :global(.hc-room) { font-weight: 600; font-family: ui-monospace, Menlo, monospace; }\n :global(.hc-bed) { font-size: 11px; color: var(--sg-muted, #64748b); }\n\n :global(.hc-risk) {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n }\n :global(.hc-risk-bar) {\n position: relative;\n width: 70px;\n height: 6px;\n background: var(--sg-border, #e2e8f0);\n border-radius: 3px;\n overflow: hidden;\n }\n :global(.hc-risk-fill) {\n position: absolute; inset: 0 auto 0 0;\n background: currentColor;\n }\n :global(.hc-risk-num) {\n font-weight: 700;\n min-width: 26px;\n }\n :global(.hc-risk-low) { color: #16a34a; }\n :global(.hc-risk-moderate) { color: #ca8a04; }\n :global(.hc-risk-high) { color: #ea580c; }\n :global(.hc-risk-critical) { color: #dc2626; }\n\n :global(.hc-code) {\n display: inline-block;\n padding: 2px 8px;\n border-radius: 4px;\n font-size: 10.5px;\n font-weight: 700;\n letter-spacing: 0.04em;\n }\n :global(.hc-code-full_code) { background: #dcfce7; color: #166534; }\n :global(.hc-code-dnr) { background: #fee2e2; color: #b91c1c; }\n :global(.hc-code-dni) { background: #fef3c7; color: #92400e; }\n :global(.hc-code-comfort_only) { background: var(--sg-bg-subtle, var(--sg-header-bg, #e2e8f0)); color: var(--sg-muted, #475569); }\n :global([data-theme='dark'] .hc-code-full_code) { background: rgba(34,197,94,.18); color: #4ade80; }\n :global([data-theme='dark'] .hc-code-dnr) { background: rgba(239,68,68,.18); color: #f87171; }\n :global([data-theme='dark'] .hc-code-dni) { background: rgba(245,158,11,.18); color: #fbbf24; }\n\n :global(.hc-vitals) {\n display: inline-flex;\n flex-direction: column;\n gap: 1px;\n color: #2563eb;\n line-height: 1.2;\n }\n :global([data-theme='dark'] .hc-vitals) { color: #60a5fa; }\n :global(.hc-spark) { width: 90px; height: 24px; }\n :global(.hc-vital-num) {\n color: var(--sg-fg, #1e293b);\n font-weight: 600;\n font-size: 12px;\n }\n :global(.hc-vital-sub) {\n color: var(--sg-muted, #64748b);\n font-size: 10.5px;\n }\n\n :global(.hc-no-allergies) {\n color: var(--sg-muted, #64748b);\n font-size: 11px;\n font-style: italic;\n }\n :global(.hc-allergy-list) {\n display: inline-flex;\n flex-wrap: wrap;\n gap: 3px;\n }\n :global(.hc-allergy) {\n background: #fee2e2;\n color: #b91c1c;\n padding: 1px 6px;\n border-radius: 999px;\n font-size: 10.5px;\n font-weight: 600;\n }\n :global([data-theme='dark'] .hc-allergy) { background: rgba(239,68,68,.18); color: #f87171; }\n</style>\n"
2369
2369
  },
2370
2370
  {
2371
2371
  "id": "410-typography-display",
@@ -3055,6 +3055,14 @@ export const examples = [
3055
3055
  "blurb": "502. Ops workspace (app template) An on-call workspace on SvDockManager. Panes: a live service grid, p50 / p95 / p99 latency and error charts for the selected service, an",
3056
3056
  "source": "<script lang=\"ts\">\n /**\n * 502. Ops workspace (app template)\n * ---------------------------------\n * An on-call workspace on SvDockManager. Panes: a live service grid,\n * p50 / p95 / p99 latency and error charts for the selected service, an\n * incident board (Board or Table over the same SvGrid), deploys, on-call and\n * an alert feed. Every pane drags, tabs, floats and auto-hides, and the\n * arrangement saves to localStorage.\n *\n * The panes run one incident loop, the way a real on-call tool does:\n * 1. A deploy goes out (progress, then live) and the service degrades.\n * 2. An alert rule fires: the row tints, the feed logs it, an incident\n * opens in Triage and the team's primary is paged.\n * 3. The primary acknowledges; the incident's timeline records each step.\n * 4. Rolling back the deploy recovers the service and moves the incident\n * to Mitigated; resolve it from the incident drawer.\n * A scripted bad deploy runs the loop a few seconds after load; Inject\n * failure runs it again on the selected service.\n *\n * Dock layout, grid, charts, drawer: @svgrid/grid.\n * Alert rules, Kanban board renderer, export: @svgrid/enterprise.\n */\n import { untrack } from 'svelte'\n import {\n SvGrid,\n SvDockManager,\n SvChart,\n SvDrawer,\n SvMenu,\n SvAvatar,\n SvToaster,\n toast,\n renderSnippet,\n dockGroup,\n dockTabs,\n dockPane,\n type ColumnDef,\n type ChartSpec,\n type DockManagerState,\n type MenuItem,\n type SvGridApi,\n type BoardCardMoveEvent,\n } from '@svgrid/grid'\n import type { ConditionalFormat } from '@svgrid/grid/format'\n import {\n SvGridAlerts,\n alertStore,\n enableAlerts,\n enableBoardView,\n installEnterprise,\n type AlertEvent,\n type AlertRule,\n type EnterpriseGridApi,\n type ExportColumn,\n type ExportFormat,\n type ExprColumn,\n } from '@svgrid/enterprise'\n\n enableAlerts()\n enableBoardView()\n\n // ---- Domain ---------------------------------------------------------------\n type Status = 'healthy' | 'degraded' | 'down'\n type Service = {\n id: string\n name: string\n team: string\n tier: 'P0' | 'P1' | 'P2'\n region: string\n rps: number\n p95: number\n errorPct: number\n cpu: number\n version: string\n status: Status\n p95History: number[]\n }\n type Baseline = { p95: number; rps: number; err: number; cpu: number }\n type IncStatus = 'triage' | 'investigating' | 'mitigated' | 'resolved'\n type Severity = 'SEV1' | 'SEV2' | 'SEV3'\n type TimelineEntry = { at: number; who: string; text: string; kind: 'open' | 'status' | 'sev' | 'page' | 'ack' | 'note' | 'deploy' }\n type Incident = {\n id: string\n title: string\n service: string\n severity: Severity\n status: IncStatus\n commander: string\n opened: string\n source: 'alert' | 'manual'\n timeline: TimelineEntry[]\n }\n type Deploy = {\n id: number\n service: string\n version: string\n from: string\n author: string\n at: number\n sha: string\n message: string\n kind: 'deploy' | 'rollback'\n state: 'deploying' | 'live'\n progress: number\n rolledBack?: boolean\n /** A bad deploy turns its service sour once it is live. */\n chaos?: Chaos['kind']\n }\n type Chaos = { kind: 'latency' | 'errors'; ticks: number }\n\n const SLO_P95 = 800\n const ERROR_LIMIT = 5\n const HISTORY = 40\n const TICK_MS = 1500\n const ME = 'Robin Hale'\n\n const ROTA: Record<string, { primary: string; secondary: string; until: number }> = {\n Payments: { primary: 'Priya Nair', secondary: 'Leo Marsh', until: 18 },\n Platform: { primary: 'Owen Bradley', secondary: 'Ana Ruiz', until: 20 },\n Growth: { primary: 'Grace Kim', secondary: 'Tom Haas', until: 17 },\n Search: { primary: 'Sam Ortiz', secondary: 'Ivy Okafor', until: 19 },\n Data: { primary: 'Mia Chen', secondary: 'Lena Fischer', until: 18 },\n }\n const PEOPLE = [ME, ...Object.values(ROTA).flatMap((r) => [r.primary, r.secondary])]\n\n const SEED: Array<Omit<Service, 'rps' | 'p95' | 'errorPct' | 'cpu' | 'status' | 'p95History'> & Baseline> = [\n { id: 'checkout-api', name: 'checkout-api', team: 'Payments', tier: 'P0', region: 'us-east', version: 'v2.13.4', p95: 310, rps: 1840, err: 0.4, cpu: 52 },\n { id: 'payments-gw', name: 'payments-gw', team: 'Payments', tier: 'P0', region: 'us-east', version: 'v5.2.0', p95: 420, rps: 960, err: 0.6, cpu: 47 },\n { id: 'auth', name: 'auth', team: 'Platform', tier: 'P0', region: 'global', version: 'v8.0.3', p95: 95, rps: 5200, err: 0.1, cpu: 38 },\n { id: 'edge-proxy', name: 'edge-proxy', team: 'Platform', tier: 'P0', region: 'global', version: 'v1.31.0', p95: 42, rps: 14800, err: 0.05, cpu: 61 },\n { id: 'catalog', name: 'catalog', team: 'Growth', tier: 'P1', region: 'eu-west', version: 'v3.9.1', p95: 180, rps: 3100, err: 0.3, cpu: 44 },\n { id: 'search', name: 'search', team: 'Search', tier: 'P1', region: 'eu-west', version: 'v12.4.2', p95: 260, rps: 2650, err: 0.5, cpu: 66 },\n { id: 'recommendations', name: 'recommendations', team: 'Growth', tier: 'P2', region: 'eu-west', version: 'v0.18.7', p95: 540, rps: 720, err: 0.8, cpu: 71 },\n { id: 'notifications', name: 'notifications', team: 'Platform', tier: 'P1', region: 'us-west', version: 'v4.4.0', p95: 150, rps: 1300, err: 0.2, cpu: 29 },\n { id: 'orders', name: 'orders', team: 'Payments', tier: 'P0', region: 'us-east', version: 'v6.1.2', p95: 230, rps: 1500, err: 0.3, cpu: 41 },\n { id: 'inventory', name: 'inventory', team: 'Data', tier: 'P1', region: 'us-west', version: 'v2.2.9', p95: 205, rps: 880, err: 0.4, cpu: 35 },\n { id: 'analytics-ingest', name: 'analytics-ingest', team: 'Data', tier: 'P2', region: 'us-west', version: 'v9.0.0', p95: 610, rps: 4100, err: 1.1, cpu: 78 },\n { id: 'media', name: 'media', team: 'Growth', tier: 'P2', region: 'ap-south', version: 'v1.6.3', p95: 330, rps: 640, err: 0.6, cpu: 33 },\n ]\n const base = new Map<string, Baseline>(SEED.map((s) => [s.id, { p95: s.p95, rps: s.rps, err: s.err, cpu: s.cpu }]))\n const teamOf = (serviceId: string) => SEED.find((s) => s.id === serviceId)?.team ?? 'Platform'\n\n const statusOf = (s: { p95: number; errorPct: number }): Status =>\n s.errorPct > ERROR_LIMIT ? 'down' : s.p95 > SLO_P95 || s.errorPct > 2 ? 'degraded' : 'healthy'\n const round = (n: number, d = 0) => Math.round(n * 10 ** d) / 10 ** d\n\n // A seeded random walk, so the first frames look the same every visit.\n let rngState = 7\n const rnd = () => ((rngState = (rngState * 16807) % 2147483647) / 2147483647)\n const shaOf = () => Array.from({ length: 7 }, () => '0123456789abcdef'[Math.floor(rnd() * 16)]).join('')\n\n const firstRows: Service[] = SEED.map((s) => {\n const hist = Array.from({ length: HISTORY }, () => round(s.p95 * (0.9 + rnd() * 0.2)))\n const row = { ...s, rps: s.rps, p95: hist[HISTORY - 1]!, errorPct: s.err, cpu: s.cpu, p95History: hist }\n return { ...row, status: statusOf(row) }\n })\n let services = $state.raw<Service[]>(firstRows)\n // Percentile and error histories live beside the rows; the grid has no use for them.\n const p50Hist = new Map<string, number[]>(firstRows.map((s) => [s.id, s.p95History.map((v) => round(v * (0.48 + rnd() * 0.06)))]))\n const p99Hist = new Map<string, number[]>(firstRows.map((s) => [s.id, s.p95History.map((v) => round(v * (1.5 + rnd() * 0.3)))]))\n const errHistory = new Map<string, number[]>(SEED.map((s) => [s.id, Array.from({ length: HISTORY }, () => round(s.err * (0.8 + rnd() * 0.4), 2))]))\n let histVersion = $state(0)\n const chaos = new Map<string, Chaos>()\n\n // Wall clock for relative times and the UTC readout, so \"3m ago\" keeps moving.\n let now = $state(Date.now())\n $effect(() => {\n const t = setInterval(() => (now = Date.now()), 1000)\n return () => clearInterval(t)\n })\n const utc = $derived(new Date(now).toISOString().slice(11, 19))\n\n let paused = $state(false)\n let tickCount = 0\n const hms = (t: number) => new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })\n let clock = $state<string[]>(Array.from({ length: HISTORY }, (_, i) => hms(Date.now() - (HISTORY - 1 - i) * TICK_MS)))\n\n function tick() {\n if (paused) return\n tickCount++\n // One scripted bad deploy a few seconds in, so the loop runs without a click.\n if (tickCount === 2) ship('checkout-api', 'latency', 'ci-bot', 'Batch tax lookups per cart instead of per line')\n advanceDeploys()\n services = services.map((s) => {\n const b = base.get(s.id)!\n const c = chaos.get(s.id)\n const wobble = () => 1 + (rnd() - 0.5) * 0.18\n let p95 = b.p95 * wobble()\n let errorPct = b.err * (0.7 + rnd() * 0.6)\n let rps = b.rps * wobble()\n let cpu = b.cpu * (0.92 + rnd() * 0.16)\n if (c) {\n const ramp = Math.min(1, (40 - c.ticks) / 4 + 0.25)\n // Additive, so even a 40 ms service crosses the SLO when it breaks.\n if (c.kind === 'latency') { p95 += (900 + rnd() * 600) * ramp; cpu = Math.min(99, cpu * 1.45); errorPct += 1.2 * ramp }\n else { errorPct = 6 + rnd() * 6 * ramp; rps *= 0.82 }\n c.ticks--\n if (c.ticks <= 0) chaos.delete(s.id)\n }\n const next = { ...s, p95: round(p95), errorPct: round(errorPct, 2), rps: round(rps), cpu: round(cpu), p95History: [...s.p95History.slice(1), round(p95)] }\n push(p50Hist, s.id, round(p95 * (0.48 + rnd() * 0.06)))\n push(p99Hist, s.id, round(p95 * (1.5 + rnd() * 0.3)))\n push(errHistory, s.id, next.errorPct)\n return { ...next, status: statusOf(next) }\n })\n histVersion++\n clock = [...clock.slice(1), hms(Date.now())]\n }\n function push(map: Map<string, number[]>, id: string, v: number) {\n const arr = map.get(id)!\n arr.push(v)\n arr.shift()\n }\n $effect(() => {\n const t = setInterval(tick, TICK_MS)\n return () => clearInterval(t)\n })\n\n // Timers the workspace starts (page acknowledgements); cleared on unmount.\n const timers = new Set<ReturnType<typeof setTimeout>>()\n const later = (ms: number, fn: () => void) => {\n const t = setTimeout(() => { timers.delete(t); fn() }, ms)\n timers.add(t)\n }\n $effect(() => () => { for (const t of timers) clearTimeout(t) })\n\n const ago = (t: number) => {\n const s = Math.max(0, Math.round((now - t) / 1000))\n if (s < 45) return 'just now'\n const m = Math.round(s / 60)\n return m < 60 ? `${m}m ago` : `${Math.floor(m / 60)}h ${m % 60}m ago`\n }\n const age = (iso: string) => {\n const m = Math.max(0, Math.floor((now - new Date(iso).getTime()) / 60_000))\n return m < 1 ? 'now' : m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`\n }\n const clockTime = (t: number) => new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })\n\n // ---- Deploys ----------------------------------------------------------------\n let deploySeq = 0\n const minutesAgo = (m: number) => Date.now() - m * 60_000\n const liveDeploy = (d: Omit<Deploy, 'id' | 'kind' | 'state' | 'progress' | 'sha'>): Deploy => ({ id: ++deploySeq, kind: 'deploy', state: 'live', progress: 100, sha: shaOf(), ...d })\n let deploys = $state<Deploy[]>([\n liveDeploy({ service: 'search', version: 'v12.4.2', from: 'v12.4.1', author: 'sam', at: minutesAgo(38), message: 'Cache facet counts per shard' }),\n liveDeploy({ service: 'auth', version: 'v8.0.3', from: 'v8.0.2', author: 'owen', at: minutesAgo(95), message: 'Rotate signing keys without dropping sessions' }),\n liveDeploy({ service: 'catalog', version: 'v3.9.1', from: 'v3.9.0', author: 'grace', at: minutesAgo(160), message: 'Variant images served from the CDN' }),\n liveDeploy({ service: 'orders', version: 'v6.1.2', from: 'v6.1.1', author: 'priya', at: minutesAgo(240), message: 'Idempotency keys on order create' }),\n ])\n const bump = (v: string) => v.replace(/(\\d+)$/, (n) => String(Number(n) + 1))\n\n /** A deploy: three ticks of progress, then live. A bad one turns its service sour. */\n function ship(id: string, kind: Chaos['kind'] | undefined, author: string, message: string) {\n const s = services.find((x) => x.id === id)\n if (!s) return\n const pending = deploys.find((d) => d.service === id && d.state === 'deploying')\n if (pending) return\n deploys = [{ id: ++deploySeq, service: id, version: bump(s.version), from: s.version, author, at: Date.now(), sha: shaOf(), message, kind: 'deploy', state: 'deploying', progress: 8, chaos: kind }, ...deploys]\n }\n function advanceDeploys() {\n if (!deploys.some((d) => d.state === 'deploying')) return\n deploys = deploys.map((d) => {\n if (d.state !== 'deploying') return d\n const progress = Math.min(100, d.progress + 34)\n if (progress < 100) return { ...d, progress }\n services = services.map((x) => (x.id === d.service ? { ...x, version: d.version } : x))\n if (d.chaos) chaos.set(d.service, { kind: d.chaos, ticks: 40 })\n untrack(() => toast(`${d.service} ${d.version} is live`, { title: 'Deploy finished' }))\n return { ...d, progress: 100, state: 'live' as const, at: Date.now() }\n })\n }\n\n /** Only the newest live entry per service can be undone, and only a deploy. */\n const latestEntry = $derived(deploys.reduceRight((m, d) => m.set(d.service, d.id), new Map<string, number>()))\n const canRollback = (d: Deploy) => d.kind === 'deploy' && d.state === 'live' && latestEntry.get(d.service) === d.id\n function rollback(d: Deploy) {\n chaos.delete(d.service)\n services = services.map((x) => (x.id === d.service ? { ...x, version: d.from } : x))\n deploys = [\n { id: ++deploySeq, service: d.service, version: d.from, from: d.version, author: 'you', at: Date.now(), sha: d.sha, message: `Revert \"${d.message}\"`, kind: 'rollback', state: 'live', progress: 100 },\n ...deploys.map((x) => (x.id === d.id ? { ...x, rolledBack: true } : x)),\n ]\n const moved = incidents.filter((i) => i.service === d.service && (i.status === 'triage' || i.status === 'investigating'))\n for (const i of incidents.filter((x) => x.service === d.service && x.status !== 'resolved')) log(i.id, ME, `Rolled back ${d.service} to ${d.from}`, 'deploy')\n for (const i of moved) setStatus(i.id, 'mitigated', ME)\n toast.success(`${d.service} rolled back to ${d.from}${moved.length ? `; ${moved.map((i) => i.id).join(', ')} moved to Mitigated` : ''}`)\n }\n\n // ---- Incidents ----------------------------------------------------------------\n let incSeq = 1040\n const isoAgo = (m: number) => new Date(minutesAgo(m)).toISOString()\n const STATUS_LABEL: Record<IncStatus, string> = { triage: 'Triage', investigating: 'Investigating', mitigated: 'Mitigated', resolved: 'Resolved' }\n let incidents = $state<Incident[]>([\n { id: `INC-${++incSeq}`, title: 'search: slow facet queries in eu-west', service: 'search', severity: 'SEV3', status: 'investigating', commander: 'Sam Ortiz', opened: isoAgo(52), source: 'manual',\n timeline: [\n { at: minutesAgo(52), who: 'Ivy Okafor', text: 'Opened from a support ticket', kind: 'open' },\n { at: minutesAgo(49), who: 'Sam Ortiz', text: 'Took command', kind: 'ack' },\n { at: minutesAgo(31), who: 'Sam Ortiz', text: 'Status: Triage to Investigating', kind: 'status' },\n { at: minutesAgo(18), who: 'Sam Ortiz', text: 'Only shards 3 and 7 are slow; their cache hit rate dropped after the reindex', kind: 'note' },\n ] },\n { id: `INC-${++incSeq}`, title: 'analytics-ingest: consumer lag above 2 min', service: 'analytics-ingest', severity: 'SEV3', status: 'mitigated', commander: 'Mia Chen', opened: isoAgo(130), source: 'alert',\n timeline: [\n { at: minutesAgo(130), who: 'Alert rules', text: 'Opened: consumer lag above 2 min', kind: 'open' },\n { at: minutesAgo(128), who: 'Mia Chen', text: 'Acknowledged the page', kind: 'ack' },\n { at: minutesAgo(96), who: 'Mia Chen', text: 'Scaled consumers from 6 to 10; lag is draining', kind: 'note' },\n { at: minutesAgo(80), who: 'Mia Chen', text: 'Status: Investigating to Mitigated', kind: 'status' },\n ] },\n { id: `INC-${++incSeq}`, title: 'auth: token refresh 401s after rotation', service: 'auth', severity: 'SEV2', status: 'resolved', commander: 'Owen Bradley', opened: isoAgo(400), source: 'alert',\n timeline: [\n { at: minutesAgo(400), who: 'Alert rules', text: 'Opened: error rate over 5%', kind: 'open' },\n { at: minutesAgo(397), who: 'Owen Bradley', text: 'Acknowledged the page', kind: 'ack' },\n { at: minutesAgo(360), who: 'Owen Bradley', text: 'Status: Mitigated to Resolved', kind: 'status' },\n ] },\n ])\n const openIncidents = $derived(incidents.filter((i) => i.status !== 'resolved').length)\n const openSev1 = $derived(incidents.filter((i) => i.status !== 'resolved' && i.severity === 'SEV1').length)\n\n function log(id: string, who: string, text: string, kind: TimelineEntry['kind']) {\n incidents = incidents.map((i) => (i.id === id ? { ...i, timeline: [...i.timeline, { at: Date.now(), who, text, kind }] } : i))\n }\n function setStatus(id: string, status: IncStatus, who = ME) {\n const inc = incidents.find((i) => i.id === id)\n if (!inc || inc.status === status) return\n log(id, who, `Status: ${STATUS_LABEL[inc.status]} to ${STATUS_LABEL[status]}`, 'status')\n incidents = incidents.map((i) => (i.id === id ? { ...i, status } : i))\n }\n function setSeverity(id: string, severity: Severity, who = ME) {\n const inc = incidents.find((i) => i.id === id)\n if (!inc || inc.severity === severity) return\n log(id, who, `Severity: ${inc.severity} to ${severity}`, 'sev')\n incidents = incidents.map((i) => (i.id === id ? { ...i, severity } : i))\n }\n function setCommander(id: string, commander: string) {\n log(id, ME, `Commander: ${commander}`, 'ack')\n incidents = incidents.map((i) => (i.id === id ? { ...i, commander } : i))\n }\n /** Page a person; they acknowledge a few seconds later, as on-call people do. */\n function page(person: string, team: string, incId?: string) {\n const targets = incId ? [incId] : incidents.filter((i) => i.status !== 'resolved' && teamOf(i.service) === team).map((i) => i.id)\n for (const id of targets) log(id, 'Paging', `Paged ${person} (${team})`, 'page')\n toast(`Paged ${person}`, { title: `${team} on-call` })\n later(4000 + Math.round(rnd() * 3000), () => {\n for (const id of targets) log(id, person, 'Acknowledged the page', 'ack')\n toast.success(`${person} acknowledged`)\n })\n }\n\n function openIncidentFor(e: AlertEvent) {\n const svc = services.find((s) => s.id === e.rowId)\n if (!svc) return\n const errors = e.ruleId === RULE_ERRORS\n const severity: Severity = svc.tier === 'P0' ? (errors ? 'SEV1' : 'SEV2') : errors ? 'SEV2' : 'SEV3'\n const existing = incidents.find((i) => i.service === svc.id && i.status !== 'resolved')\n if (existing) {\n // Escalate an open incident rather than opening a second one.\n if (severity < existing.severity) {\n setSeverity(existing.id, severity, 'Alert rules')\n toast.warning(`${existing.id} escalated to ${severity}`)\n }\n return\n }\n const rota = ROTA[svc.team]\n const inc: Incident = {\n id: `INC-${++incSeq}`,\n title: errors ? `${svc.name}: error rate above ${ERROR_LIMIT}%` : `${svc.name}: p95 latency over the ${SLO_P95} ms SLO`,\n service: svc.id, severity, status: 'triage', commander: rota?.primary ?? 'Unassigned', opened: new Date().toISOString(), source: 'alert',\n timeline: [{ at: Date.now(), who: 'Alert rules', text: `Opened: ${e.ruleName}`, kind: 'open' }],\n }\n incidents = [inc, ...incidents]\n if (rota) page(rota.primary, svc.team, inc.id)\n }\n function newIncident() {\n const svc = services.find((s) => s.id === selectedId) ?? services[0]!\n const id = `INC-${++incSeq}`\n incidents = [{ id, title: `${svc.name}: reported by support`, service: svc.id, severity: 'SEV3', status: 'triage', commander: ME, opened: new Date().toISOString(), source: 'manual',\n timeline: [{ at: Date.now(), who: ME, text: 'Opened by hand', kind: 'open' }] }, ...incidents]\n drawerId = id\n }\n function onCardMove(e: BoardCardMoveEvent<Incident>) {\n setStatus(e.row.id, e.toLane as IncStatus)\n }\n\n // ---- Incident drawer --------------------------------------------------------\n let drawerId = $state<string | null>(null)\n const drawerInc = $derived(incidents.find((i) => i.id === drawerId) ?? null)\n const drawerSvc = $derived(drawerInc ? services.find((s) => s.id === drawerInc.service) : undefined)\n const drawerDeploy = $derived(drawerInc ? deploys.find((d) => d.service === drawerInc.service && d.kind === 'deploy') : undefined)\n let note = $state('')\n function addNote() {\n const text = note.trim()\n if (!text || !drawerInc) return\n log(drawerInc.id, ME, text, 'note')\n note = ''\n }\n\n // ---- Alert rules (Enterprise) ---------------------------------------------\n const RULE_P95 = 'ops-p95-slo'\n const RULE_ERRORS = 'ops-error-rate'\n const OUR_RULES = new Set([RULE_P95, RULE_ERRORS])\n const exprColumns: ExprColumn[] = [\n { id: 'name', name: 'Service', type: 'text' },\n { id: 'team', name: 'Team', type: 'text' },\n { id: 'tier', name: 'Tier', type: 'text' },\n { id: 'region', name: 'Region', type: 'text' },\n { id: 'rps', name: 'Requests / s', type: 'number' },\n { id: 'p95', name: 'p95 latency (ms)', type: 'number' },\n { id: 'errorPct', name: 'Error rate (%)', type: 'number' },\n { id: 'cpu', name: 'CPU (%)', type: 'number' },\n ]\n const seededRules: AlertRule[] = [\n {\n id: RULE_P95, name: `p95 over the ${SLO_P95} ms SLO`, enabled: true, severity: 'warning', scope: 'row',\n predicate: { kind: 'cmp', column: 'p95', op: 'greaterThan', value: SLO_P95 },\n trigger: { type: 'dataChange' },\n actions: [{ kind: 'toast', message: '{name}: p95 is {value} ms' }, { kind: 'highlight', style: { background: '#fef3c7', color: '#92400e' } }],\n createdAt: 0,\n },\n {\n id: RULE_ERRORS, name: `Error rate over ${ERROR_LIMIT}%`, enabled: true, severity: 'error', scope: 'row',\n predicate: { kind: 'cmp', column: 'errorPct', op: 'greaterThan', value: ERROR_LIMIT },\n trigger: { type: 'dataChange' },\n actions: [{ kind: 'toast', message: '{name}: error rate {value}%' }, { kind: 'highlight', style: { background: '#fee2e2', color: '#991b1b' } }],\n createdAt: 0,\n },\n ]\n let alertFormats = $state<ConditionalFormat<Service>[]>([])\n\n // The alert log is shared across the page; only events from this\n // workspace's rules count, and only the ones fired after it mounted.\n const keyOf = (e: AlertEvent) => `${e.ruleId}|${e.rowId ?? ''}|${e.firedAt}`\n const mountedAt = Date.now()\n const seen = new Set(alertStore.events.map(keyOf))\n const feed = $derived(alertStore.events.filter((e) => OUR_RULES.has(e.ruleId) && e.firedAt >= mountedAt))\n const unacked = $derived(feed.filter((e) => !e.acknowledged).length)\n /** Acknowledge this workspace's alerts only; the log is shared with the page. */\n function ackAll() {\n for (const e of feed) {\n if (!e.acknowledged) alertStore.acknowledge(alertStore.events.indexOf(e))\n }\n }\n const incidentForService = (id: string | undefined) => incidents.find((i) => i.service === id && i.status !== 'resolved')\n $effect(() => {\n const events = alertStore.events\n untrack(() => {\n for (const e of [...events].reverse()) {\n const k = keyOf(e)\n if (seen.has(k)) continue\n seen.add(k)\n if (OUR_RULES.has(e.ruleId) && e.rowId) openIncidentFor(e)\n }\n })\n })\n\n // ---- Selection + charts -------------------------------------------------------\n let selectedId = $state('checkout-api')\n const selected = $derived(services.find((s) => s.id === selectedId) ?? services[0]!)\n const latencySpec = $derived.by<ChartSpec>(() => {\n void histVersion\n return {\n type: 'line',\n categories: clock,\n xAxis: { labelRotation: 0 },\n series: [\n { label: 'p99', values: [...(p99Hist.get(selected.id) ?? [])], color: '#93c5fd', marker: 'none', smooth: 'monotone' },\n { label: 'p95', values: selected.p95History, color: '#2563eb', type: 'area', marker: 'none', smooth: 'monotone' },\n { label: 'p50', values: [...(p50Hist.get(selected.id) ?? [])], color: '#64748b', marker: 'none', smooth: 'monotone' },\n ],\n referenceLines: [{ value: SLO_P95, label: `SLO ${SLO_P95} ms`, dashed: true, color: '#dc2626' }],\n yAxisTitle: 'ms',\n }\n })\n const errorSpec = $derived.by<ChartSpec>(() => {\n void histVersion\n return {\n type: 'area',\n categories: clock,\n xAxis: { labelRotation: 0 },\n series: [{ label: 'Errors (%)', values: [...(errHistory.get(selected.id) ?? [])], color: '#d97706', marker: 'none', smooth: 'monotone' }],\n referenceLines: [{ value: ERROR_LIMIT, label: `Limit ${ERROR_LIMIT}%`, dashed: true, color: '#dc2626' }],\n yAxisTitle: '%',\n }\n })\n\n // ---- KPIs ---------------------------------------------------------------------\n const healthy = $derived(services.filter((s) => s.status === 'healthy').length)\n const fleetP95 = $derived.by(() => {\n const sorted = services.map((s) => s.p95).sort((a, b) => a - b)\n return sorted[Math.floor(sorted.length / 2)] ?? 0\n })\n const totalRps = $derived(services.reduce((n, s) => n + s.rps, 0))\n const fleetErr = $derived(round(services.reduce((n, s) => n + s.errorPct * s.rps, 0) / Math.max(1, totalRps), 2))\n\n // ---- Grids --------------------------------------------------------------------\n let servicesApi = $state<EnterpriseGridApi<any, Service> | null>(null)\n let incidentsApi = $state<EnterpriseGridApi<any, Incident> | null>(null)\n let incidentView = $state<'board' | 'table'>('board')\n\n const serviceColumns: ColumnDef<any, Service>[] = [\n { id: 'name', field: 'name', header: 'Service', width: 190, cell: (ctx) => renderSnippet(ServiceCell, { row: ctx.row.original }) },\n { id: 'status', field: 'status', header: 'Status', width: 104, cell: (ctx) => renderSnippet(StatusCell, { row: ctx.row.original }) },\n { id: 'tier', field: 'tier', header: 'Tier', width: 64 },\n { id: 'p95', field: 'p95', header: 'p95 ms', width: 88, format: { type: 'number' } },\n { id: 'trend', field: 'p95History', header: 'p95, last 60 s', width: 130, sparkline: { type: 'area', color: '#2563eb', lineWidth: 1.5 } },\n { id: 'errorPct', field: 'errorPct', header: 'Errors', width: 84, cell: (ctx) => renderSnippet(ErrorCell, { row: ctx.row.original }) },\n { id: 'rps', field: 'rps', header: 'Req/s', width: 88, format: { type: 'number' } },\n { id: 'cpu', field: 'cpu', header: 'CPU %', width: 76, format: { type: 'number' } },\n { id: 'region', field: 'region', header: 'Region', width: 92 },\n { id: 'version', field: 'version', header: 'Version', width: 96 },\n ]\n const incidentColumns: ColumnDef<any, Incident>[] = [\n { field: 'id', header: 'ID', width: 96, editable: false },\n { field: 'title', header: 'Title', width: 280, editorType: 'text' },\n { field: 'severity', header: 'Sev', width: 80, editorType: 'list', editorOptions: ['SEV1', 'SEV2', 'SEV3'] },\n { field: 'status', header: 'Status', width: 130, editorType: 'list', editorOptions: ['triage', 'investigating', 'mitigated', 'resolved'] },\n { field: 'commander', header: 'Commander', width: 140, editorType: 'text' },\n { field: 'service', header: 'Service', width: 140, editable: false },\n { field: 'opened', header: 'Opened', width: 150, editable: false, format: { type: 'datetime', pattern: 'short' } },\n ]\n const lanes = [\n { id: 'triage', title: 'Triage', color: '#dc2626' },\n { id: 'investigating', title: 'Investigating', color: '#d97706' },\n { id: 'mitigated', title: 'Mitigated', color: '#2563eb' },\n { id: 'resolved', title: 'Resolved', color: '#16a34a' },\n ]\n\n // ---- Export (Enterprise) ------------------------------------------------------\n const SERVICE_EXPORT: ExportColumn<Service>[] = [\n { field: 'name', header: 'Service' }, { field: 'team', header: 'Team' }, { field: 'tier', header: 'Tier' },\n { field: 'status', header: 'Status' }, { field: 'p95', header: 'p95 (ms)' }, { field: 'errorPct', header: 'Error rate (%)' },\n { field: 'rps', header: 'Requests / s' }, { field: 'cpu', header: 'CPU (%)' }, { field: 'region', header: 'Region' }, { field: 'version', header: 'Version' },\n ]\n const INCIDENT_EXPORT: ExportColumn<Incident>[] = [\n { field: 'id', header: 'ID' }, { field: 'title', header: 'Title' }, { field: 'severity', header: 'Severity' }, { field: 'status', header: 'Status' },\n { field: 'commander', header: 'Commander' }, { field: 'service', header: 'Service' },\n { field: 'opened', header: 'Opened', format: { type: 'datetime', pattern: 'short' } }, { field: 'source', header: 'Source' },\n ]\n const stamp = () => {\n const d = new Date()\n const pad = (n: number) => String(n).padStart(2, '0')\n return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`\n }\n async function run(label: string, job: () => Promise<unknown> | undefined) {\n try {\n await job()\n toast.success(`Exported ${label}`)\n } catch (err) {\n toast.error(`Export failed: ${err instanceof Error ? err.message : String(err)}`)\n }\n }\n const exportServices = (format: ExportFormat) =>\n run(`services to ${format.toUpperCase()}`, () => servicesApi?.exportData({ format, filename: `services-${stamp()}`, columns: SERVICE_EXPORT, rows: 'all' }))\n const exportIncidents = (format: ExportFormat) =>\n run(`incidents to ${format.toUpperCase()}`, () => incidentsApi?.exportData({ format, filename: `incidents-${stamp()}`, columns: INCIDENT_EXPORT, rows: incidents }))\n const exportItems: MenuItem[] = [\n { label: 'Services snapshot to Excel', onSelect: () => exportServices('xlsx') },\n { label: 'Services snapshot to PDF', onSelect: () => exportServices('pdf') },\n { separator: true },\n { label: 'Incident log to Excel', onSelect: () => exportIncidents('xlsx') },\n { label: 'Incident log to CSV', onSelect: () => exportIncidents('csv') },\n ]\n const injectItems = $derived<MenuItem[]>([\n { label: `Bad deploy on ${selected.name}: latency`, onSelect: () => ship(selected.id, 'latency', 'you', 'Switch the connection pool to lazy init') },\n { label: `Bad deploy on ${selected.name}: errors`, onSelect: () => ship(selected.id, 'errors', 'you', 'Tighten request validation') },\n { separator: true },\n { label: `Clean deploy on ${selected.name}`, onSelect: () => ship(selected.id, undefined, 'you', 'Bump dependencies') },\n ])\n\n // ---- Dock layout ----------------------------------------------------------------\n // The board sits under the service grid, in the wide column, so all four\n // lanes fit; the charts and logs are narrow by nature.\n const LAYOUT_KEY = 'svgrid-demo-ops-layout-v3'\n const defaultLayout = (): DockManagerState => ({\n main: dockGroup('row', [\n dockGroup('column', [\n dockTabs([dockPane('services', 'Services', { closable: false, minSize: 180 })]),\n dockTabs([dockPane('incidents', 'Incidents', { minSize: 200 })]),\n ], [0.5, 0.5]),\n dockGroup('column', [\n dockTabs([dockPane('latency', 'Latency'), dockPane('errors', 'Errors')]),\n dockTabs([dockPane('deploys', 'Deploys'), dockPane('oncall', 'On-call'), dockPane('feed', 'Alert feed')]),\n ], [0.5, 0.5]),\n ], [0.64, 0.36]),\n floating: [],\n autoHide: [],\n })\n function loadLayout(): DockManagerState {\n try {\n const s = localStorage.getItem(LAYOUT_KEY)\n if (s) return JSON.parse(s) as DockManagerState\n } catch {\n /* storage blocked or malformed: fall back to the default */\n }\n return defaultLayout()\n }\n let workspace = $state<DockManagerState>(loadLayout())\n function saveLayout() {\n try {\n localStorage.setItem(LAYOUT_KEY, JSON.stringify(workspace))\n toast.success('Layout saved; it comes back on reload')\n } catch {\n toast.error('Could not save: storage is blocked in this browser')\n }\n }\n function resetLayout() {\n workspace = defaultLayout()\n try { localStorage.removeItem(LAYOUT_KEY) } catch { /* ignore */ }\n }\n\n function onServicesReady(a: SvGridApi<any, Service>) { servicesApi = installEnterprise(a) }\n function onIncidentsReady(a: SvGridApi<any, Incident>) { incidentsApi = installEnterprise(a) }\n\n // Status and severity colours carry meaning, so they stay literal.\n const STATUS_COLOR: Record<Status, string> = { healthy: '#16a34a', degraded: '#d97706', down: '#dc2626' }\n const SEV_COLOR: Record<Severity, string> = { SEV1: '#dc2626', SEV2: '#d97706', SEV3: '#64748b' }\n const SEVERITY_COLOR: Record<string, string> = { error: '#dc2626', warning: '#d97706', info: '#2563eb', success: '#16a34a' }\n const LANE_COLOR: Record<IncStatus, string> = { triage: '#dc2626', investigating: '#d97706', mitigated: '#2563eb', resolved: '#16a34a' }\n const TL_ICON: Record<TimelineEntry['kind'], string> = {\n open: 'M12 8v4 M12 16h.01 M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z',\n status: 'M5 12h14 M13 6l6 6-6 6',\n sev: 'M12 3 2 20h20z M12 10v4 M12 17h.01',\n page: 'M6 16v-5a6 6 0 0 1 12 0v5l2 2H4z M10 21h4',\n ack: 'M5 13l4 4L19 7',\n note: 'M4 5h16v11H8l-4 4z',\n deploy: 'M9 14 4 9l5-5 M4 9h10a6 6 0 0 1 0 12h-3',\n }\n const SEVERITIES: Severity[] = ['SEV1', 'SEV2', 'SEV3']\n const STATUSES: IncStatus[] = ['triage', 'investigating', 'mitigated', 'resolved']\n const ROTA_ROWS = Object.entries(ROTA)\n</script>\n\n{#snippet ico(d: string, size = 15)}\n <svg viewBox=\"0 0 24 24\" width={size} height={size} fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path {d} /></svg>\n{/snippet}\n\n{#snippet ServiceCell(props: { row: Service })}\n <span class=\"ops-svc\">\n <span class=\"ops-dot\" style:background={STATUS_COLOR[props.row.status]}></span>\n <span class=\"ops-svc-txt\"><strong>{props.row.name}</strong><span>{props.row.team}</span></span>\n </span>\n{/snippet}\n{#snippet StatusCell(props: { row: Service })}\n <span class=\"ops-pill\" style:--c={STATUS_COLOR[props.row.status]}>{props.row.status}</span>\n{/snippet}\n{#snippet ErrorCell(props: { row: Service })}\n <span class=\"ops-num\" class:is-bad={props.row.errorPct > ERROR_LIMIT} class:is-warn={props.row.errorPct > 2 && props.row.errorPct <= ERROR_LIMIT}>{props.row.errorPct.toFixed(2)}%</span>\n{/snippet}\n{#snippet incidentCard(i: Incident)}\n <!-- svelte-ignore a11y_click_events_have_key_events -->\n <!-- svelte-ignore a11y_no_static_element_interactions -->\n <div class=\"ops-card\" onclick={() => (drawerId = i.id)} title=\"Open {i.id}\">\n <div class=\"ops-card-top\">\n <span class=\"ops-sev\" style:--c={SEV_COLOR[i.severity]}>{i.severity}</span>\n <span class=\"ops-card-id\">{i.id}</span>\n <span class=\"ops-card-age\">{age(i.opened)}</span>\n </div>\n <div class=\"ops-card-title\">{i.title}</div>\n <div class=\"ops-card-foot\">\n <SvAvatar name={i.commander} size={18} />\n <span class=\"ops-card-who\">{i.commander}</span>\n {#if i.source === 'alert'}<span class=\"ops-card-src\" title=\"Opened by an alert rule\" aria-label=\"Opened by an alert rule\">{@render ico('M6 16v-5a6 6 0 0 1 12 0v5l2 2H4z M10 21h4', 12)}</span>{/if}\n </div>\n </div>\n{/snippet}\n\n<div class=\"ops\">\n <header class=\"ops-head\">\n <div class=\"ops-title\">\n <span class=\"ops-logo\">{@render ico('M3 12h4l3-8 4 16 3-8h4', 18)}</span>\n <div>\n <h2>Ops center <span class=\"ops-live\" class:is-paused={paused}>{paused ? 'Paused' : 'Live'}</span></h2>\n <p><span class=\"ops-env\">production</span> us-east-1 · {services.length} services · <span class=\"ops-utc\">{utc} UTC</span></p>\n </div>\n </div>\n <div class=\"ops-tools\">\n <button type=\"button\" class=\"ops-btn\" onclick={() => (paused = !paused)}>{@render ico(paused ? 'M8 5v14l11-7z' : 'M7 5h3v14H7z M14 5h3v14h-3z')}{paused ? 'Resume' : 'Pause'}</button>\n <SvMenu items={injectItems}>\n {#snippet anchor()}<button type=\"button\" class=\"ops-btn\">{@render ico('M12 19V5 M5 12l7-7 7 7')}Deploy</button>{/snippet}\n </SvMenu>\n <SvMenu items={exportItems}>\n {#snippet anchor()}<button type=\"button\" class=\"ops-btn\">{@render ico('M12 3v12 M8 11l4 4 4-4 M4 17v3h16v-3')}Export</button>{/snippet}\n </SvMenu>\n <button type=\"button\" class=\"ops-btn\" onclick={saveLayout}>Save layout</button>\n <button type=\"button\" class=\"ops-btn\" onclick={resetLayout}>Reset</button>\n <SvGridAlerts data={services} columns={exprColumns} getRowId={(s) => s.id} rules={seededRules} storageKey=\"svgrid-demo:ops-alerts\" bind:formats={alertFormats} toastCooldownMs={8000} onJump={(e) => { if (e.rowId) selectedId = e.rowId }} />\n </div>\n </header>\n\n <div class=\"ops-kpis\">\n <div class=\"ops-kpi\"><span>Healthy</span><strong class:is-warn={healthy < services.length}>{healthy}<small> / {services.length}</small></strong></div>\n <div class=\"ops-kpi\"><span>Median p95</span><strong>{fleetP95}<small> ms</small></strong></div>\n <div class=\"ops-kpi\"><span>Error rate</span><strong class:is-bad={fleetErr > 2}>{fleetErr.toFixed(2)}<small>%</small></strong></div>\n <div class=\"ops-kpi\"><span>Traffic</span><strong>{Math.round(totalRps / 100) / 10}<small>k req/s</small></strong></div>\n <div class=\"ops-kpi\"><span>Open incidents</span><strong class:is-bad={openSev1 > 0}>{openIncidents}{#if openSev1}<small> ({openSev1} SEV1)</small>{/if}</strong></div>\n <div class=\"ops-kpi\"><span>Alerts fired</span><strong>{feed.length}{#if unacked}<small> ({unacked} new)</small>{/if}</strong></div>\n </div>\n\n <div class=\"ops-stage\">\n <SvDockManager bind:workspace minSize={120} keepAlive>\n {#snippet pane(p)}\n {#if p.id === 'services'}\n <div class=\"ops-pane\">\n <SvGrid\n data={services}\n columns={serviceColumns}\n getRowId={(s) => s.id}\n conditionalFormats={alertFormats}\n sortable\n selectionMode=\"none\"\n enableRowSummaries={false}\n rowHeight={44}\n containerHeight=\"100%\"\n initialColumnPinning={{ left: ['name'] }}\n columnVirtualization={false}\n rowClass={({ row }) => ({ 'ops-row-sel': row.id === selectedId })}\n onRowClick={(e) => (selectedId = e.row.id)}\n onApiReady={onServicesReady}\n />\n </div>\n {:else if p.id === 'latency' || p.id === 'errors'}\n {@const inc = incidentForService(selected.id)}\n <div class=\"ops-pane ops-chart\">\n <div class=\"ops-chart-head\">\n <strong>{selected.name}</strong>\n <span class=\"ops-pill\" style:--c={STATUS_COLOR[selected.status]}>{selected.status}</span>\n <span class=\"ops-muted\">{p.id === 'latency' ? `p95 ${selected.p95} ms` : `errors ${selected.errorPct.toFixed(2)}%`} · {selected.version} · on call {ROTA[selected.team]?.primary}</span>\n {#if inc}<button type=\"button\" class=\"ops-link\" onclick={() => (drawerId = inc.id)}>{inc.id}</button>{/if}\n </div>\n <div class=\"ops-chart-body\">\n <SvChart spec={p.id === 'latency' ? latencySpec : errorSpec} legend={p.id === 'latency'} autosize animate={false} />\n </div>\n </div>\n {:else if p.id === 'incidents'}\n <div class=\"ops-pane ops-inc\">\n <div class=\"ops-inc-bar\">\n <div class=\"ops-seg\" role=\"group\" aria-label=\"Incident view\">\n <button type=\"button\" class:is-on={incidentView === 'board'} aria-pressed={incidentView === 'board'} onclick={() => (incidentView = 'board')}>Board</button>\n <button type=\"button\" class:is-on={incidentView === 'table'} aria-pressed={incidentView === 'table'} onclick={() => (incidentView = 'table')}>Table</button>\n </div>\n <span class=\"ops-muted\">The same grid, two views. {openIncidents} open; click one for its timeline.</span>\n <button type=\"button\" class=\"ops-btn ops-btn-sm\" onclick={newIncident}>{@render ico('M12 5v14 M5 12h14', 13)}New</button>\n </div>\n <div class=\"ops-inc-grid\">\n {#if incidentView === 'board'}\n <SvGrid\n data={incidents}\n columns={incidentColumns}\n getRowId={(i) => i.id}\n containerHeight=\"100%\"\n onApiReady={onIncidentsReady}\n board={{ groupBy: 'status', lanes, card: incidentCard, onCardMove, searchable: false, laneSummary: (list) => `${list.length}` }}\n />\n {:else}\n <SvGrid\n data={incidents}\n columns={incidentColumns}\n getRowId={(i) => i.id}\n sortable\n filterable\n enableInlineEditing\n selectionMode=\"none\"\n enableRowSummaries={false}\n rowHeight={36}\n containerHeight=\"100%\"\n onApiReady={onIncidentsReady}\n onRowDoubleClick={(e) => (drawerId = e.row.id)}\n onCellValueChange={() => (incidents = [...incidents])}\n />\n {/if}\n </div>\n </div>\n {:else if p.id === 'deploys'}\n <div class=\"ops-pane ops-list\">\n {#each deploys as d (d.id)}\n <div class=\"ops-dep\">\n <span class=\"ops-dep-ic\" class:is-rb={d.kind === 'rollback'}>{@render ico(d.kind === 'rollback' ? 'M9 14 4 9l5-5 M4 9h10a6 6 0 0 1 0 12h-3' : 'M12 19V5 M5 12l7-7 7 7', 13)}</span>\n <div class=\"ops-dep-txt\">\n <span><strong>{d.service}</strong> {d.from} → {d.version}{#if d.rolledBack}<span class=\"ops-tag\">rolled back</span>{/if}</span>\n <span class=\"ops-dep-msg\">{d.message}</span>\n <span class=\"ops-muted\"><code>{d.sha}</code> · {d.kind === 'rollback' ? `rollback by ${d.author}` : `by ${d.author}`} · {d.state === 'deploying' ? 'deploying' : ago(d.at)}</span>\n {#if d.state === 'deploying'}<span class=\"ops-progress\" role=\"progressbar\" aria-valuenow={d.progress} aria-valuemin={0} aria-valuemax={100} aria-label=\"Deploy progress\"><span style:width=\"{d.progress}%\"></span></span>{/if}\n </div>\n {#if canRollback(d)}\n <button type=\"button\" class=\"ops-btn ops-btn-sm\" onclick={() => rollback(d)}>Roll back</button>\n {/if}\n </div>\n {/each}\n </div>\n {:else if p.id === 'oncall'}\n <div class=\"ops-pane ops-list\">\n {#each ROTA_ROWS as [team, r] (team)}\n {@const open = incidents.filter((i) => i.status !== 'resolved' && teamOf(i.service) === team).length}\n <div class=\"ops-dep\">\n <SvAvatar name={r.primary} size={30} status=\"online\" />\n <div class=\"ops-dep-txt\">\n <span><strong>{r.primary}</strong> <span class=\"ops-muted\">· {team}</span>{#if open}<span class=\"ops-tag ops-tag-bad\">{open} open</span>{/if}</span>\n <span class=\"ops-muted\">Primary until {r.until}:00 · secondary {r.secondary}</span>\n </div>\n <button type=\"button\" class=\"ops-btn ops-btn-sm\" onclick={() => page(r.primary, team)}>Page</button>\n </div>\n {/each}\n </div>\n {:else if p.id === 'feed'}\n <div class=\"ops-pane ops-list\">\n {#if feed.length}\n <div class=\"ops-list-head\">\n <span class=\"ops-muted\">{unacked} unacknowledged of {feed.length}</span>\n <button type=\"button\" class=\"ops-btn ops-btn-sm\" disabled={unacked === 0} onclick={ackAll}>Acknowledge all</button>\n </div>\n {/if}\n {#if feed.length === 0}\n <div class=\"ops-empty\">No alerts yet. The scripted deploy trips one a few seconds after load, or ship a bad one from Deploy.</div>\n {/if}\n {#each feed as e (keyOf(e))}\n {@const inc = incidentForService(e.rowId)}\n <div class=\"ops-alert\" class:is-ack={e.acknowledged}>\n <span class=\"ops-dot\" style:background={SEVERITY_COLOR[e.severity] ?? '#64748b'}></span>\n <div class=\"ops-dep-txt\">\n <span>{e.message}</span>\n <span class=\"ops-muted\">{e.ruleName} · {clockTime(e.firedAt)}{#if inc} · <button type=\"button\" class=\"ops-link\" onclick={() => (drawerId = inc.id)}>{inc.id}</button>{/if}</span>\n </div>\n {#if !e.acknowledged}\n <button type=\"button\" class=\"ops-btn ops-btn-sm\" onclick={() => alertStore.acknowledge(alertStore.events.indexOf(e))}>Ack</button>\n {/if}\n </div>\n {/each}\n </div>\n {/if}\n {/snippet}\n </SvDockManager>\n </div>\n</div>\n\n<SvDrawer open={drawerInc != null} onClose={() => (drawerId = null)} side=\"right\" size=\"440px\" title={drawerInc ? `${drawerInc.id} · ${drawerInc.title}` : ''}>\n {#if drawerInc}\n {@const inc = drawerInc}\n <div class=\"ops-dr\">\n <div class=\"ops-dr-row\">\n <span class=\"ops-dr-label\">Severity</span>\n <div class=\"ops-seg\" role=\"group\" aria-label=\"Severity\">\n {#each SEVERITIES as s (s)}\n <button type=\"button\" class:is-on={inc.severity === s} style:--c={SEV_COLOR[s]} aria-pressed={inc.severity === s} onclick={() => setSeverity(inc.id, s)}>{s}</button>\n {/each}\n </div>\n </div>\n <div class=\"ops-dr-row\">\n <span class=\"ops-dr-label\">Status</span>\n <div class=\"ops-seg\" role=\"group\" aria-label=\"Status\">\n {#each STATUSES as s (s)}\n <button type=\"button\" class:is-on={inc.status === s} aria-pressed={inc.status === s} onclick={() => setStatus(inc.id, s)}>{STATUS_LABEL[s]}</button>\n {/each}\n </div>\n </div>\n <div class=\"ops-dr-row\">\n <span class=\"ops-dr-label\">Commander</span>\n <select class=\"ops-select\" value={inc.commander} onchange={(e) => setCommander(inc.id, e.currentTarget.value)} aria-label=\"Commander\">\n {#each PEOPLE as name (name)}<option value={name}>{name}</option>{/each}\n </select>\n <button type=\"button\" class=\"ops-btn ops-btn-sm\" onclick={() => page(inc.commander, teamOf(inc.service), inc.id)}>Page</button>\n </div>\n\n {#if drawerSvc}\n <div class=\"ops-dr-card\">\n <div class=\"ops-dr-card-head\">\n <span class=\"ops-dot\" style:background={STATUS_COLOR[drawerSvc.status]}></span>\n <strong>{drawerSvc.name}</strong>\n <span class=\"ops-pill\" style:--c={STATUS_COLOR[drawerSvc.status]}>{drawerSvc.status}</span>\n <button type=\"button\" class=\"ops-link\" onclick={() => (selectedId = drawerSvc.id)}>Show in charts</button>\n </div>\n <div class=\"ops-dr-stats\">\n <span><small>p95</small>{drawerSvc.p95} ms</span>\n <span><small>Errors</small>{drawerSvc.errorPct.toFixed(2)}%</span>\n <span><small>Traffic</small>{drawerSvc.rps.toLocaleString()} req/s</span>\n <span><small>Version</small>{drawerSvc.version}</span>\n </div>\n {#if drawerDeploy}\n <div class=\"ops-dr-deploy\">\n <span class=\"ops-muted\">Last deploy {drawerDeploy.from} → {drawerDeploy.version} by {drawerDeploy.author}, {drawerDeploy.state === 'deploying' ? 'in progress' : ago(drawerDeploy.at)}{#if drawerDeploy.rolledBack} (rolled back){/if}</span>\n {#if canRollback(drawerDeploy)}<button type=\"button\" class=\"ops-btn ops-btn-sm\" onclick={() => drawerDeploy && rollback(drawerDeploy)}>Roll back</button>{/if}\n </div>\n {/if}\n </div>\n {/if}\n\n <h3 class=\"ops-dr-h\">Timeline</h3>\n <ol class=\"ops-tl\">\n {#each [...inc.timeline].reverse() as t, i (t.at + t.text + i)}\n <li class=\"ops-tl-item\" data-kind={t.kind}>\n <span class=\"ops-tl-ic\">{@render ico(TL_ICON[t.kind], 13)}</span>\n <div class=\"ops-tl-txt\">\n <span>{#if t.kind === 'note'}<strong>{t.who}</strong> {t.text}{:else}{t.text}{#if t.who !== 'Alert rules' && t.who !== 'Paging' && !t.text.includes(t.who)}{' '}<span class=\"ops-muted\">by {t.who}</span>{/if}{/if}</span>\n <span class=\"ops-muted\">{clockTime(t.at)} · {ago(t.at)}</span>\n </div>\n </li>\n {/each}\n </ol>\n <form class=\"ops-note\" onsubmit={(e) => { e.preventDefault(); addNote() }}>\n <textarea bind:value={note} rows=\"2\" placeholder=\"Add a note to the timeline\" aria-label=\"Note\" onkeydown={(e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); addNote() } }}></textarea>\n <button type=\"submit\" class=\"ops-btn ops-btn-sm\" disabled={!note.trim()}>Add note</button>\n </form>\n </div>\n {/if}\n {#snippet footer()}\n {#if drawerInc}\n {@const inc = drawerInc}\n <div class=\"ops-dr-foot\">\n <span class=\"ops-muted\">Opened {age(inc.opened) === 'now' ? 'just now' : `${age(inc.opened)} ago`} {inc.source === 'alert' ? 'by an alert rule' : 'by hand'}</span>\n {#if inc.status === 'resolved'}\n <button type=\"button\" class=\"ops-btn\" onclick={() => setStatus(inc.id, 'investigating')}>Reopen</button>\n {:else}\n <button type=\"button\" class=\"ops-btn ops-btn-primary\" style:--c={LANE_COLOR.resolved} onclick={() => { setStatus(inc.id, 'resolved'); toast.success(`${inc.id} resolved`) }}>Resolve</button>\n {/if}\n </div>\n {/if}\n {/snippet}\n</SvDrawer>\n\n<SvToaster position=\"bottom-right\" />\n\n<style>\n .ops {\n display: flex;\n flex-direction: column;\n flex: 1 1 auto;\n min-height: 760px;\n height: 100%;\n border: 1px solid var(--sg-border, #e5e7eb);\n border-radius: 12px;\n overflow: hidden;\n background: var(--sg-bg, #fff);\n color: var(--sg-fg, #0f172a);\n font-size: 13px;\n }\n .ops-muted { color: var(--sg-muted, #64748b); }\n\n .ops-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 12px 16px; border-bottom: 1px solid var(--sg-border, #e5e7eb); flex: none; }\n .ops-title { display: flex; align-items: center; gap: 12px; min-width: 0; }\n .ops-logo { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 8px; background: var(--sg-fg, #0f172a); color: var(--sg-bg, #fff); flex: none; }\n .ops-title h2 { margin: 0; font-size: 16px; font-weight: 650; display: flex; align-items: center; gap: 8px; }\n .ops-title p { margin: 2px 0 0; font-size: 12px; color: var(--sg-muted, #64748b); display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }\n /* Production red-ish pill is a warning label, like every deploy tool shows. */\n .ops-env { font-size: 10.5px; font-weight: 700; padding: 0 6px; border-radius: 4px; color: #b91c1c; background: color-mix(in srgb, #dc2626 12%, transparent); }\n .ops-utc { font-variant-numeric: tabular-nums; }\n /* Live / paused are status, so green and amber stay literal. */\n .ops-live { font-size: 10.5px; font-weight: 700; letter-spacing: 0.03em; text-transform: uppercase; color: #15803d; background: color-mix(in srgb, #16a34a 14%, transparent); padding: 2px 8px; border-radius: 999px; }\n .ops-live.is-paused { color: #b45309; background: color-mix(in srgb, #d97706 14%, transparent); }\n .ops-tools { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }\n .ops-btn {\n display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 11px;\n border: 1px solid var(--sg-border, #e5e7eb); border-radius: 7px; background: var(--sg-bg, #fff);\n color: var(--sg-fg, #0f172a); font: inherit; font-size: 12.5px; font-weight: 500; cursor: pointer; white-space: nowrap;\n }\n .ops-btn:hover { background: var(--sg-row-hover-bg, #f1f5f9); }\n .ops-btn:focus-visible { outline: 2px solid var(--sg-accent, #2563eb); outline-offset: 1px; }\n .ops-btn:disabled { opacity: 0.45; cursor: default; }\n .ops-btn-sm { height: 26px; padding: 0 8px; font-size: 12px; }\n .ops-btn-primary { background: var(--c); border-color: var(--c); color: #fff; }\n .ops-btn-primary:hover { background: color-mix(in srgb, var(--c) 88%, #000); }\n .ops-link { border: 0; background: none; padding: 0; font: inherit; font-size: 12px; font-weight: 600; color: var(--sg-accent, #2563eb); cursor: pointer; }\n .ops-link:hover { text-decoration: underline; }\n\n .ops-kpis { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); border-bottom: 1px solid var(--sg-border, #e5e7eb); flex: none; }\n .ops-kpi { display: flex; flex-direction: column; gap: 2px; padding: 9px 16px; border-right: 1px solid var(--sg-border, #e5e7eb); min-width: 0; }\n .ops-kpi:last-child { border-right: 0; }\n .ops-kpi span { font-size: 11px; color: var(--sg-muted, #64748b); white-space: nowrap; }\n .ops-kpi strong { font-size: 19px; font-weight: 650; font-variant-numeric: tabular-nums; line-height: 1.15; white-space: nowrap; }\n .ops-kpi small { font-size: 12px; font-weight: 500; color: var(--sg-muted, #64748b); }\n .ops-kpi strong.is-bad, .ops :global(.ops-num.is-bad) { color: #dc2626; }\n .ops-kpi strong.is-warn, .ops :global(.ops-num.is-warn) { color: #b45309; }\n .ops-kpi strong.is-bad small, .ops-kpi strong.is-warn small { color: inherit; opacity: 0.8; }\n\n .ops-stage { flex: 1 1 0; min-height: 0; padding: 8px; display: flex; flex-direction: column; }\n .ops-stage > :global(*) { flex: 1 1 0; min-height: 0; }\n .ops-pane { height: 100%; min-height: 0; display: flex; flex-direction: column; box-sizing: border-box; }\n .ops-pane > :global(.sv-grid-root-fill), .ops-pane > :global(*:only-child) { flex: 1 1 0; min-height: 0; }\n .ops-pane :global(.sv-grid-row) { cursor: pointer; }\n\n /* Service grid cells */\n .ops :global(.ops-svc) { display: inline-flex; align-items: center; gap: 9px; min-width: 0; }\n .ops :global(.ops-dot), .ops-dr :global(.ops-dot) { width: 8px; height: 8px; border-radius: 999px; flex: none; display: inline-block; }\n .ops :global(.ops-svc-txt) { display: flex; flex-direction: column; line-height: 1.2; min-width: 0; }\n .ops :global(.ops-svc-txt strong) { font-weight: 600; overflow: hidden; text-overflow: ellipsis; }\n .ops :global(.ops-svc-txt span) { font-size: 11.5px; color: var(--sg-muted, #64748b); }\n .ops :global(.ops-pill), .ops-dr .ops-pill { display: inline-block; font-size: 11px; font-weight: 600; padding: 1px 8px; border-radius: 999px; color: var(--c); background: color-mix(in srgb, var(--c) 14%, transparent); text-transform: capitalize; }\n .ops :global(.ops-num) { font-variant-numeric: tabular-nums; }\n .ops :global(.ops-row-sel > td) { background: var(--sg-selection-bg, #eff6ff); }\n\n /* Charts */\n .ops-chart-head { display: flex; align-items: center; gap: 8px; padding: 8px 12px 0; flex: none; flex-wrap: wrap; }\n .ops-chart-head .ops-muted { font-size: 12px; }\n .ops-chart-body { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; padding: 4px 8px 8px; }\n .ops-chart-body > :global(*) { flex: 1 1 0; min-height: 0; }\n\n /* Incidents */\n .ops-inc { --sg-board-lane-w: 158px; }\n .ops-inc-bar { display: flex; align-items: center; gap: 10px; padding: 8px 10px; flex: none; }\n .ops-inc-bar .ops-muted { flex: 1; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n .ops-inc-grid { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; }\n .ops-inc-grid > :global(*) { flex: 1 1 0; min-height: 0; }\n .ops-seg { display: inline-flex; border: 1px solid var(--sg-border, #e5e7eb); border-radius: 7px; overflow: hidden; flex: none; }\n .ops-seg button { border: 0; background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); font: inherit; font-size: 12px; padding: 4px 11px; cursor: pointer; }\n .ops-seg button + button { border-left: 1px solid var(--sg-border, #e5e7eb); }\n .ops-seg button.is-on { background: var(--c, var(--sg-fg, #0f172a)); color: var(--sg-bg, #fff); }\n .ops :global(.ops-card) { display: flex; flex-direction: column; gap: 6px; cursor: pointer; }\n .ops :global(.ops-card-top) { display: flex; align-items: center; gap: 6px; font-size: 11px; white-space: nowrap; }\n .ops :global(.ops-sev) { font-weight: 700; padding: 0 6px; border-radius: 4px; color: var(--c); background: color-mix(in srgb, var(--c) 14%, transparent); }\n .ops :global(.ops-card-id) { color: var(--sg-muted, #64748b); font-variant-numeric: tabular-nums; }\n .ops :global(.ops-card-src) { display: inline-flex; margin-left: auto; color: var(--sg-muted, #64748b); }\n .ops :global(.ops-card-who) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }\n .ops :global(.ops-card-age) { margin-left: auto; color: var(--sg-muted, #64748b); }\n .ops :global(.ops-card-title) { font-weight: 600; font-size: 12.5px; line-height: 1.35; }\n .ops :global(.ops-card-foot) { display: flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--sg-muted, #64748b); }\n\n /* Lists: deploys, on-call, alert feed */\n .ops-list { overflow: auto; padding: 0 0 4px; }\n .ops-list-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 12px; border-bottom: 1px solid var(--sg-border, #e5e7eb); font-size: 12px; position: sticky; top: 0; background: var(--sg-bg, #fff); z-index: 1; }\n .ops-dep, .ops-alert { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-bottom: 1px solid var(--sg-border, #e5e7eb); }\n .ops-dep:hover, .ops-alert:hover { background: var(--sg-row-hover-bg, #f8fafc); }\n .ops-dep-ic { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 6px; flex: none; border: 1px solid var(--sg-border, #e5e7eb); color: var(--sg-muted, #64748b); }\n .ops-dep-ic.is-rb { color: #b45309; }\n .ops-dep-txt { display: flex; flex-direction: column; gap: 1px; flex: 1; min-width: 0; }\n .ops-dep-txt > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n .ops-dep-txt .ops-muted { font-size: 11.5px; }\n .ops-dep-msg { font-size: 12px; }\n .ops-dep-txt code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; }\n .ops-progress { display: block; height: 4px; margin-top: 4px; border-radius: 2px; background: var(--sg-border, #e5e7eb); overflow: hidden; }\n .ops-progress > span { display: block; height: 100%; background: var(--sg-accent, #2563eb); transition: width 1.2s linear; }\n .ops-alert.is-ack { opacity: 0.55; }\n .ops-tag { margin-left: 6px; font-size: 10.5px; padding: 0 6px; border-radius: 4px; border: 1px solid var(--sg-border, #e5e7eb); color: var(--sg-muted, #64748b); }\n .ops-tag-bad { color: #b91c1c; border-color: color-mix(in srgb, #dc2626 35%, transparent); }\n .ops-empty { padding: 18px 14px; color: var(--sg-muted, #64748b); font-size: 12.5px; line-height: 1.5; }\n\n /* Incident drawer (portalled, so its rules are not under .ops) */\n .ops-dr { display: flex; flex-direction: column; gap: 14px; font-size: 13px; color: var(--sg-fg, #0f172a); }\n .ops-dr .ops-muted { color: var(--sg-muted, #64748b); }\n .ops-dr-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }\n .ops-dr-label { width: 78px; font-size: 12px; font-weight: 600; color: var(--sg-muted, #64748b); }\n .ops-select { font: inherit; font-size: 13px; padding: 4px 8px; border: 1px solid var(--sg-input-border, var(--sg-border, #d1d5db)); border-radius: 6px; background: var(--sg-input-bg, var(--sg-bg, #fff)); color: var(--sg-fg, #0f172a); }\n .ops-dr-card { border: 1px solid var(--sg-border, #e5e7eb); border-radius: 10px; overflow: hidden; }\n .ops-dr-card-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; background: var(--sg-header-bg, #f8fafc); border-bottom: 1px solid var(--sg-border, #e5e7eb); }\n .ops-dr-card-head .ops-link { margin-left: auto; }\n .ops-dr-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); padding: 10px 12px; gap: 8px; font-variant-numeric: tabular-nums; }\n .ops-dr-stats span { display: flex; flex-direction: column; font-weight: 600; }\n .ops-dr-stats small { font-size: 11px; font-weight: 500; color: var(--sg-muted, #64748b); }\n .ops-dr-deploy { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 12px; border-top: 1px solid var(--sg-border, #e5e7eb); font-size: 12px; }\n .ops-dr-h { margin: 4px 0 0; font-size: 13px; font-weight: 650; }\n .ops-tl { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; }\n .ops-tl-item { position: relative; display: flex; gap: 10px; padding: 0 0 12px; }\n .ops-tl-item:not(:last-child)::before { content: ''; position: absolute; left: 11px; top: 24px; bottom: 0; width: 1px; background: var(--sg-border, #e5e7eb); }\n .ops-tl-ic { display: grid; place-items: center; width: 23px; height: 23px; border-radius: 999px; flex: none; border: 1px solid var(--sg-border, #e5e7eb); background: var(--sg-bg, #fff); color: var(--sg-muted, #64748b); }\n /* Timeline kinds use the same status palette as the board. */\n .ops-tl-item[data-kind='open'] .ops-tl-ic { color: #dc2626; }\n .ops-tl-item[data-kind='ack'] .ops-tl-ic { color: #15803d; }\n .ops-tl-item[data-kind='deploy'] .ops-tl-ic { color: #b45309; }\n .ops-tl-item[data-kind='page'] .ops-tl-ic { color: #2563eb; }\n .ops-tl-txt { display: flex; flex-direction: column; gap: 1px; min-width: 0; padding-top: 2px; }\n .ops-tl-txt .ops-muted { font-size: 11.5px; }\n .ops-note { display: flex; flex-direction: column; gap: 6px; align-items: flex-end; }\n .ops-note textarea { width: 100%; box-sizing: border-box; resize: vertical; font: inherit; font-size: 13px; padding: 7px 9px; border: 1px solid var(--sg-input-border, var(--sg-border, #d1d5db)); border-radius: 6px; background: var(--sg-input-bg, var(--sg-bg, #fff)); color: var(--sg-fg, #0f172a); }\n .ops-note textarea:focus { outline: none; border-color: var(--sg-accent, #2563eb); }\n .ops-dr-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; font-size: 12px; }\n .ops-dr .ops-btn, .ops-dr-foot .ops-btn { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 11px; border: 1px solid var(--sg-border, #e5e7eb); border-radius: 7px; background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); font: inherit; font-size: 12.5px; cursor: pointer; }\n .ops-dr .ops-btn-sm { height: 26px; padding: 0 8px; font-size: 12px; }\n .ops-dr-foot .ops-btn-primary { background: var(--c); border-color: var(--c); color: #fff; }\n .ops-dr .ops-btn:disabled { opacity: 0.45; cursor: default; }\n .ops-dr .ops-link { border: 0; background: none; padding: 0; font: inherit; font-size: 12px; font-weight: 600; color: var(--sg-accent, #2563eb); cursor: pointer; }\n .ops-dr .ops-seg { display: inline-flex; border: 1px solid var(--sg-border, #e5e7eb); border-radius: 7px; overflow: hidden; }\n .ops-dr .ops-seg button { border: 0; background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); font: inherit; font-size: 12px; padding: 4px 10px; cursor: pointer; }\n .ops-dr .ops-seg button + button { border-left: 1px solid var(--sg-border, #e5e7eb); }\n .ops-dr .ops-seg button.is-on { background: var(--c, var(--sg-fg, #0f172a)); color: var(--sg-bg, #fff); }\n\n @media (max-width: 900px) {\n .ops-kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); }\n .ops-kpi:nth-child(3) { border-right: 0; }\n .ops-kpi:nth-child(-n + 3) { border-bottom: 1px solid var(--sg-border, #e5e7eb); }\n }\n @media (max-width: 639px), (max-height: 500px) and (pointer: coarse) {\n .ops { flex-shrink: 0; min-height: 900px; }\n .ops-dr-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }\n }\n</style>\n"
3057
3057
  },
3058
+ {
3059
+ "id": "503-big-data",
3060
+ "path": "examples/src/demos/503-big-data.svelte",
3061
+ "title": "Big Data",
3062
+ "category": "Server-Side Row Model",
3063
+ "blurb": "503. Big data: up to 10,000,000 rows x 10,000 columns One SvGrid on the Enterprise server row model. The rows live behind a Web Worker that answers the model's requests like a database would: it sorts",
3064
+ "source": "<script lang=\"ts\">\n /**\n * 503. Big data: up to 10,000,000 rows x 10,000 columns\n * ------------------------------------------------------\n * One SvGrid on the Enterprise server row model. The rows live behind a Web\n * Worker that answers the model's requests like a database would: it sorts\n * (radix, exact), filters, groups and applies edits over a generated\n * dataset, and sends back only the ids of the rows asked for. The page\n * builds just the rows on screen, so the grid's memory and per-scroll work\n * do not grow with the row count.\n *\n * Pick a size, then sort a column, type in a filter, group, or edit a cell\n * (balance, rating, status, game, bought, the months and the metrics are\n * editable). \"Run benchmark\" times each of those against the current size\n * and lists the results. Every number on the page is measured live in this\n * browser.\n */\n import { onDestroy, onMount } from 'svelte'\n import {\n SvGrid,\n renderComponent,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n type ColumnDef,\n type ConditionalFormat,\n type SvGridApi,\n } from '@svgrid/grid'\n import { createServerRowModel, serverGroupText, SvGroupCell, type ServerRowModelGridRow } from '@svgrid/enterprise'\n import { createBigDataClient, type Activity, type BigRow } from '../shared/bigdata/client'\n import {\n columnsFor,\n COUNTRY_BY_NAME,\n CONTINENTS,\n GAMES,\n LANGUAGES,\n STATUSES,\n type BigColumn,\n } from '../shared/bigdata/data'\n\n // What the grid holds: a data row or a group row, each carrying __group.\n type GridRow = ServerRowModelGridRow<BigRow>\n\n const ROW_OPTIONS = [10_000, 100_000, 1_000_000, 10_000_000]\n const COL_OPTIONS = [10, 25, 100, 1_000, 10_000]\n const GROUPINGS: Array<{ label: string; by: string[] }> = [\n { label: 'None', by: [] },\n { label: 'Continent > Country', by: ['continent', 'country'] },\n { label: 'Country', by: ['country'] },\n { label: 'Game', by: ['game'] },\n { label: 'Status', by: ['status'] },\n { label: 'Rating', by: ['rating'] },\n ]\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n const compact = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 })\n const full = new Intl.NumberFormat('en-US')\n const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })\n const fmtMs = (ms: number) => (ms >= 1000 ? `${(ms / 1000).toFixed(2)} s` : ms >= 10 ? `${Math.round(ms)} ms` : `${ms.toFixed(1)} ms`)\n const sizeLabel = (n: number) => (n >= 1_000_000 ? `${n / 1_000_000}M` : n >= 1_000 ? `${n / 1_000}K` : String(n))\n\n let rowCount = $state(1_000_000)\n let colCount = $state(25)\n let grouping = $state(0)\n let search = $state('')\n let activity = $state<Activity>({})\n let ctl = $state<ReturnType<typeof createServerRowModel<BigRow>> | null>(null)\n let columns = $state<ColumnDef<any, GridRow>[]>([])\n let pinning = $state<{ left: string[]; right: string[] }>({ left: [], right: [] })\n let api: SvGridApi<any, GridRow> | null = null\n let gridKey = $state(0)\n let readyMs = $state<number | null>(null)\n let editNote = $state('')\n\n const client = createBigDataClient((a) => (activity = a))\n\n // ---- columns ---------------------------------------------------------------\n\n const chip = (labels: readonly string[]) => ({ getValue }: { getValue: () => unknown }) => {\n const value = getValue()\n const i = labels.indexOf(String(value))\n return i < 0 ? 'bd-chip' : `bd-chip bd-c${i % 8}`\n }\n const moneyClass = ({ getValue }: { getValue: () => unknown }) => {\n const value = getValue()\n const n = Number(value)\n if (!Number.isFinite(n)) return ''\n return n < 0 ? 'bd-neg' : n >= 15_000 ? 'bd-pos3' : n >= 8_000 ? 'bd-pos2' : n >= 2_000 ? 'bd-pos1' : ''\n }\n const heatClass = ({ getValue }: { getValue: () => unknown }) => {\n const value = getValue()\n const n = Number(value)\n return Number.isFinite(n) ? `bd-h${Math.min(9, Math.max(0, Math.floor(n / 100)))}` : ''\n }\n const stars = (v: unknown) => {\n const n = Math.round(Number(v))\n return Number.isFinite(n) && n >= 1 ? '★'.repeat(Math.min(5, n)) + '☆'.repeat(Math.max(0, 5 - n)) : String(v ?? '')\n }\n\n /** A data row, as opposed to a group row of the server row model. */\n const isLeaf = (row: { original?: unknown } | undefined) =>\n (row?.original as { __group?: { kind?: string } } | undefined)?.__group?.kind === 'leaf'\n /** Format data rows; a group row has no value in this column, so show nothing. */\n const leafOnly =\n (fmt: (value: unknown) => string) =>\n ({ value, row }: { value: unknown; row: { original?: unknown } }) =>\n isLeaf(row) ? fmt(value) : ''\n const editableLeaf = (ctx: { row: { original?: unknown } }) => isLeaf(ctx.row)\n\n function toColumnDef(c: BigColumn): ColumnDef<any, GridRow> {\n const base = { field: c.field, header: c.header, width: c.width } as ColumnDef<any, GridRow>\n switch (c.field) {\n case 'id':\n return { ...base, align: 'right', cellClass: 'bd-id', editable: false, formatter: leafOnly((v) => full.format(Number(v))) }\n case 'name':\n // The group column too: a group row shows its chevron, label and row\n // count here; a data row shows the person's name.\n return {\n id: 'name',\n header: 'Name',\n width: 250,\n editable: false,\n cellClass: 'bd-name',\n fieldFn: (row) => serverGroupText(row as never, 'name'),\n cell: (ctx) =>\n renderComponent(SvGroupCell, {\n row: ctx.row.original as never,\n onToggle: () => ctl?.group.onToggle(ctx.row.original as never),\n leafField: 'name',\n }),\n }\n case 'email':\n return { ...base, cellClass: 'bd-muted', editable: false, formatter: leafOnly((v) => String(v)) }\n case 'country':\n return { ...base, editable: false, formatter: leafOnly((v) => `${COUNTRY_BY_NAME.get(String(v))?.flag ?? ''} ${String(v)}`) }\n case 'language':\n return { ...base, editable: false, cellClass: chip(LANGUAGES), formatter: leafOnly((v) => String(v)) }\n case 'continent':\n return { ...base, editable: false, cellClass: chip(CONTINENTS), formatter: leafOnly((v) => String(v)) }\n case 'game':\n return { ...base, cellClass: chip(GAMES), editable: editableLeaf, editorType: 'select', editorOptions: [...GAMES], formatter: leafOnly((v) => String(v)) }\n case 'bought':\n // A boolean renders as a checkbox on data rows; group rows have none.\n return { ...base, align: 'center', editable: editableLeaf, formatter: leafOnly((v) => (v ? 'Yes' : 'No')) }\n case 'rating':\n return {\n ...base,\n cellClass: 'bd-stars',\n editable: editableLeaf,\n editorType: 'number',\n // On a group row the value is the average rating.\n formatter: ({ value, row }) => (isLeaf(row) ? stars(value) : value == null ? '' : `avg ${Number(value).toFixed(1)}`),\n }\n case 'balance':\n return { ...base, align: 'right', editable: editableLeaf, editorType: 'number', formatter: ({ value }) => (value == null ? '' : usd.format(Number(value))) }\n case 'status':\n return { ...base, editable: editableLeaf, editorType: 'select', editorOptions: [...STATUSES], cellClass: ({ getValue }) => { const value = getValue(); return value ? `bd-pill bd-s-${String(value).toLowerCase()}` : '' }, formatter: leafOnly((v) => String(v)) }\n case 'joined':\n return { ...base, editable: false, cellClass: 'bd-muted', formatter: leafOnly((v) => String(v)) }\n case 'total':\n return { ...base, align: 'right', editable: false, cellClass: 'bd-total', formatter: ({ value }) => (value == null ? '' : usd.format(Number(value))) }\n default:\n if (c.kind === 'money') {\n return { ...base, align: 'right', editable: editableLeaf, editorType: 'number', cellClass: moneyClass, formatter: leafOnly((v) => usd.format(Number(v))) }\n }\n // A metric is not on the row object: read it from the row index, so\n // 10,000 columns cost nothing until a cell is on screen.\n return {\n id: c.field,\n header: c.header,\n width: c.width,\n fieldFn: (row) => client.cell(row, c.field),\n align: 'right',\n editable: editableLeaf,\n editorType: 'number',\n cellClass: heatClass,\n formatter: ({ value }) => (value == null ? '' : Number(value).toFixed(2)),\n }\n }\n }\n\n // Data bars with fixed ranges: the grid never scans the 10M rows for a min\n // and max, it scales each cell against the range the data is drawn from.\n const conditionalFormats: ConditionalFormat<GridRow>[] = [\n { type: 'dataBar', columns: ['balance'], color: '#3b82f6', gradient: true, minValue: 0, maxValue: 999_999 },\n { type: 'dataBar', columns: ['total'], color: '#10b981', negativeColor: '#ef4444', minValue: -60_000, maxValue: 240_000 },\n ]\n\n // ---- the dataset -----------------------------------------------------------\n\n async function rebuild() {\n readyMs = null\n editNote = ''\n const t0 = performance.now()\n ctl?.dispose()\n ctl = null\n await client.configure(rowCount, colCount)\n const cols = columnsFor(colCount)\n columns = cols.map(toColumnDef)\n const fields = new Set(cols.map((c) => c.field))\n pinning = {\n left: fields.has('name') ? ['name'] : [],\n right: fields.has('total') ? ['total'] : [],\n }\n ctl = createServerRowModel<BigRow>(client.source, {\n blockSize: 100,\n maxBlocksInCache: 80,\n blockLoadDebounceMs: 30,\n keepRowsWhileLoading: 800,\n groupBy: GROUPINGS[grouping]!.by.filter((f) => fields.has(f)),\n aggregations: [\n { col: 'balance', fn: 'sum' },\n { col: 'total', fn: 'sum' },\n { col: 'rating', fn: 'avg' },\n ],\n childCount: (r) => Number((r as Record<string, unknown>).__count) || undefined,\n getRowId: (r) => String(r.id),\n })\n gridKey += 1\n await waitPainted()\n readyMs = performance.now() - t0\n }\n\n function setGrouping(i: number) {\n grouping = i\n ctl?.setGroupBy(GROUPINGS[i]!.by.filter((f) => columns.some((c) => c.field === f)))\n }\n\n let searchTimer: ReturnType<typeof setTimeout> | null = null\n function onSearch(value: string) {\n search = value\n if (searchTimer) clearTimeout(searchTimer)\n searchTimer = setTimeout(() => {\n if (ctl) ctl.setFilter({ ...ctl.getState().filterModel, global: value })\n }, 250)\n }\n\n async function onEdit(e: { row: GridRow; columnId: string; newValue: unknown; oldValue: unknown }) {\n const meta = (e.row as Record<string, unknown>).__group as { kind?: string } | undefined\n if (meta && meta.kind !== 'leaf') return\n if (Object.is(e.newValue, e.oldValue)) return\n const value = typeof e.oldValue === 'number' ? Number(e.newValue) : e.newValue\n await ctl?.updateRow(String(e.row.id), { [e.columnId]: value } as Partial<BigRow>)\n const r = activity.edit\n editNote = r ? `Saved in ${fmtMs(r.ms)} in the worker; ${r.patchedViews} cached view${r.patchedViews === 1 ? '' : 's'} patched in place` : ''\n }\n\n // ---- measuring -------------------------------------------------------------\n\n /** Resolve once the rows on screen are real rows, not loading placeholders. */\n function waitPainted(timeout = 180_000): Promise<void> {\n const start = performance.now()\n return new Promise((resolve) => {\n const check = () => {\n const host = document.querySelector('.bd-grid')\n const rows = host ? [...host.querySelectorAll('tbody tr')] : []\n const busy = rows.some((r) => r.getAttribute('aria-busy') === 'true')\n const loading = !!host?.querySelector('.sv-grid-loading-overlay, .sv-grid-skeleton-row')\n if ((rows.length > 0 && !busy && !loading) || performance.now() - start > timeout) {\n requestAnimationFrame(() => requestAnimationFrame(() => resolve()))\n return\n }\n requestAnimationFrame(check)\n }\n requestAnimationFrame(check)\n })\n }\n\n // Frame meter: frames per second and the longest frame in the last second.\n let fps = $state(0)\n let longest = $state(0)\n let domCells = $state(0)\n let heapMb = $state<number | null>(null)\n let raf = 0\n onMount(() => {\n let frames = 0\n let worst = 0\n let last = performance.now()\n let windowStart = last\n const loop = (t: number) => {\n frames += 1\n worst = Math.max(worst, t - last)\n last = t\n if (t - windowStart >= 1000) {\n fps = Math.round((frames * 1000) / (t - windowStart))\n longest = worst\n frames = 0\n worst = 0\n windowStart = t\n domCells = document.querySelectorAll('.bd-grid td').length\n const mem = (performance as unknown as { memory?: { usedJSHeapSize: number } }).memory\n heapMb = mem ? Math.round(mem.usedJSHeapSize / 1e6) : null\n }\n raf = requestAnimationFrame(loop)\n }\n raf = requestAnimationFrame(loop)\n void rebuild()\n })\n onDestroy(() => {\n cancelAnimationFrame(raf)\n ctl?.dispose()\n client.dispose()\n })\n\n // ---- benchmark -----------------------------------------------------------------\n\n type BenchRow = { step: string; ms: number; worker?: number; note: string }\n let bench = $state<BenchRow[]>([])\n let benchRunning = $state(false)\n\n /**\n * Resolve when the step is really done: no request is waiting on the worker\n * and the rows on screen are real, for four frames running (so a debounced\n * request has had its chance to start). Returns the time of the first of\n * those frames, so the wait itself is not counted.\n */\n function waitSettled(timeout = 600_000): Promise<number> {\n const start = performance.now()\n let quietSince = 0\n let quietFrames = 0\n return new Promise((resolve) => {\n const check = (t: number) => {\n const host = document.querySelector('.bd-grid')\n const rows = host ? [...host.querySelectorAll('tbody tr')] : []\n const busy = rows.some((r) => r.getAttribute('aria-busy') === 'true')\n const quiet = client.pending() === 0 && rows.length > 0 && !busy\n if (quiet) {\n if (quietFrames === 0) quietSince = t\n quietFrames += 1\n } else {\n quietFrames = 0\n }\n if (quietFrames >= 4 || t - start > timeout) {\n resolve(quietFrames >= 4 ? quietSince : t)\n return\n }\n requestAnimationFrame(check)\n }\n requestAnimationFrame(check)\n })\n }\n\n async function timed(step: string, note: string, action: () => unknown): Promise<void> {\n const seq = activity.compute?.seq ?? 0\n const t0 = performance.now()\n await action()\n const settled = await waitSettled()\n const ms = settled - t0\n // The worker's compute time, if this step made the worker compute anything.\n const w = activity.compute\n bench = [...bench, { step, ms, worker: w && w.seq > seq ? w.ms : undefined, note }]\n }\n\n function scroller(): HTMLElement | null {\n return document.querySelector('.bd-grid .sv-grid-container')\n }\n\n async function runBenchmark() {\n if (benchRunning) return\n benchRunning = true\n bench = []\n grouping = 0\n search = ''\n try {\n await timed('Load the dataset', `${full.format(rowCount)} rows x ${full.format(colCount)} columns`, () => rebuild())\n await timed('Jump to the middle', `row ${full.format(Math.floor(rowCount / 2))}`, () => {\n const s = scroller()\n if (s) s.scrollTop = (s.scrollHeight - s.clientHeight) / 2\n })\n await timed('Jump to the last row', `row ${full.format(rowCount)}`, () => {\n const s = scroller()\n if (s) s.scrollTop = s.scrollHeight\n })\n if (colCount >= 100) {\n await timed('Scroll to the last column', `${full.format(colCount)} columns`, () => {\n const s = scroller()\n if (s) s.scrollLeft = s.scrollWidth\n })\n }\n await timed('Sort by bank balance', 'radix sort, every row', () => api?.setSort('balance', 'desc'))\n await timed('Filter: country is Japan', 'one pass over every row', () =>\n api?.setFilter('country', { operator: 'equals', value: 'Japan' } as never),\n )\n await timed('Clear the filter', 'back to the sorted view, cached', () => api?.clearAllFilters?.())\n await timed('Group by continent and country', 'counts and sums in one pass', () => setGrouping(1))\n await timed('Expand Europe', 'the first level of children', () => ctl?.expandGroup(['Europe']))\n await timed('Ungroup', '', () => setGrouping(0))\n } finally {\n benchRunning = false\n }\n }\n\n const cells = $derived(rowCount * colCount)\n const opLabel: Record<string, string> = {\n identity: 'Served in id order',\n cached: 'Served from the cached view',\n scan: 'Filtered',\n sort: 'Sorted',\n 'scan+sort': 'Filtered and sorted',\n group: 'Grouped',\n }\n</script>\n\n<section class=\"bd-wrap\">\n <div class=\"bd-controls\">\n <div class=\"bd-field\">\n <span class=\"bd-label\">Rows</span>\n <div class=\"bd-seg\" role=\"radiogroup\" aria-label=\"Rows\">\n {#each ROW_OPTIONS as n (n)}\n <button role=\"radio\" aria-checked={rowCount === n} class:on={rowCount === n} disabled={benchRunning} onclick={() => { rowCount = n; void rebuild() }}>{sizeLabel(n)}</button>\n {/each}\n </div>\n </div>\n <div class=\"bd-field\">\n <span class=\"bd-label\">Columns</span>\n <div class=\"bd-seg\" role=\"radiogroup\" aria-label=\"Columns\">\n {#each COL_OPTIONS as n (n)}\n <button role=\"radio\" aria-checked={colCount === n} class:on={colCount === n} disabled={benchRunning} onclick={() => { colCount = n; void rebuild() }}>{sizeLabel(n)}</button>\n {/each}\n </div>\n </div>\n <div class=\"bd-field\">\n <span class=\"bd-label\">Group by</span>\n <select value={grouping} disabled={benchRunning} onchange={(e) => setGrouping(Number(e.currentTarget.value))}>\n {#each GROUPINGS as g, i (g.label)}<option value={i}>{g.label}</option>{/each}\n </select>\n </div>\n <div class=\"bd-field bd-grow\">\n <span class=\"bd-label\">Search</span>\n <input type=\"search\" placeholder=\"name, email, country, game, status\" value={search} oninput={(e) => onSearch(e.currentTarget.value)} />\n </div>\n <button class=\"bd-run\" onclick={runBenchmark} disabled={benchRunning}>{benchRunning ? 'Running...' : 'Run benchmark'}</button>\n </div>\n\n <div class=\"bd-stats\">\n <div class=\"bd-stat\"><span class=\"k\">Rows</span><span class=\"v\">{full.format(rowCount)}</span></div>\n <div class=\"bd-stat\"><span class=\"k\">Columns</span><span class=\"v\">{full.format(colCount)}</span></div>\n <div class=\"bd-stat\"><span class=\"k\">Cells</span><span class=\"v\">{compact.format(cells)}</span></div>\n <div class=\"bd-stat\"><span class=\"k\">Cells in the DOM</span><span class=\"v\">{full.format(domCells)}</span></div>\n <div class=\"bd-stat\"><span class=\"k\">Frame rate</span><span class=\"v\">{fps} fps</span><span class=\"s\">longest frame {fmtMs(longest)}</span></div>\n {#if heapMb != null}<div class=\"bd-stat\"><span class=\"k\">JS heap</span><span class=\"v\">{heapMb} MB</span></div>{/if}\n <div class=\"bd-stat bd-wide\">\n <span class=\"k\">Last query</span>\n {#if activity.progress}\n <span class=\"v\">{activity.progress.phase}...</span>\n <span class=\"bd-bar\"><span style={`width:${Math.round((activity.progress.done / Math.max(1, activity.progress.total)) * 100)}%`}></span></span>\n {:else if activity.last}\n <span class=\"v\">{fmtMs(activity.last.ms)}</span>\n <span class=\"s\">{opLabel[activity.last.op] ?? activity.last.op}{activity.last.scanned ? `, ${compact.format(activity.last.scanned)} rows read` : ''} - worker time</span>\n {:else}\n <span class=\"v\">-</span>\n {/if}\n </div>\n {#if readyMs != null}<div class=\"bd-stat\"><span class=\"k\">Ready in</span><span class=\"v\">{fmtMs(readyMs)}</span></div>{/if}\n </div>\n\n {#if editNote}<p class=\"bd-note\">{editNote}</p>{/if}\n\n {#if bench.length}\n <div class=\"bd-bench\">\n <table>\n <thead><tr><th>Step</th><th>On screen</th><th>Worker</th><th></th></tr></thead>\n <tbody>\n {#each bench as b (b.step)}\n <tr><td>{b.step}</td><td class=\"num\">{fmtMs(b.ms)}</td><td class=\"num\">{b.worker != null ? fmtMs(b.worker) : '-'}</td><td class=\"dim\">{b.note}</td></tr>\n {/each}\n </tbody>\n </table>\n </div>\n {/if}\n\n <div class=\"bd-grid\">\n {#if ctl}\n {#key gridKey}\n <SvGrid\n rowModel={ctl}\n {columns}\n {features}\n {conditionalFormats}\n initialColumnPinning={pinning}\n sortable\n filterable\n filterMode=\"menu\"\n editable\n columnResize\n stickyGroupRows\n rowHeight={34}\n containerHeight=\"100%\"\n onCellValueChange={onEdit}\n onApiReady={(next) => (api = next)}\n />\n {/key}\n {/if}\n </div>\n</section>\n\n<style>\n .bd-wrap {\n flex: 1;\n min-height: 0;\n display: flex;\n flex-direction: column;\n gap: 10px;\n color: var(--sg-fg, #0f172a);\n }\n .bd-controls {\n display: flex;\n flex-wrap: wrap;\n align-items: flex-end;\n gap: 12px;\n }\n .bd-field { display: flex; flex-direction: column; gap: 4px; }\n .bd-grow { flex: 1 1 200px; }\n .bd-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--sg-muted, #64748b); }\n .bd-seg {\n display: inline-flex;\n border: 1px solid var(--sg-border, #e2e8f0);\n border-radius: 8px;\n overflow: hidden;\n }\n .bd-seg button {\n font: inherit;\n font-size: 13px;\n padding: 6px 11px;\n border: 0;\n border-right: 1px solid var(--sg-border, #e2e8f0);\n background: var(--sg-bg, #fff);\n color: var(--sg-fg, #0f172a);\n cursor: pointer;\n font-variant-numeric: tabular-nums;\n }\n .bd-seg button:last-child { border-right: 0; }\n .bd-seg button.on { background: var(--sg-fg, #0f172a); color: var(--sg-bg, #fff); font-weight: 600; }\n .bd-seg button:disabled { cursor: default; opacity: 0.6; }\n .bd-controls select,\n .bd-controls input {\n font: inherit;\n font-size: 13px;\n padding: 6px 10px;\n border-radius: 8px;\n border: 1px solid var(--sg-border, #e2e8f0);\n background: var(--sg-bg, #fff);\n color: var(--sg-fg, #0f172a);\n }\n .bd-run {\n font: inherit;\n font-weight: 600;\n font-size: 13px;\n padding: 7px 14px;\n border-radius: 8px;\n border: 1px solid var(--sg-fg, #0f172a);\n background: var(--sg-fg, #0f172a);\n color: var(--sg-bg, #fff);\n cursor: pointer;\n }\n .bd-run:disabled { opacity: 0.6; cursor: default; }\n .bd-seg button:focus-visible,\n .bd-run:focus-visible,\n .bd-controls select:focus-visible,\n .bd-controls input:focus-visible { outline: 2px solid var(--sg-accent, #ea580c); outline-offset: 1px; }\n\n .bd-stats { display: flex; flex-wrap: wrap; gap: 8px; }\n .bd-stat {\n display: flex;\n flex-direction: column;\n min-width: 120px;\n padding: 8px 12px;\n border: 1px solid var(--sg-border, #e2e8f0);\n border-radius: 10px;\n background: var(--sg-bg, #fff);\n }\n .bd-stat.bd-wide { min-width: 260px; flex: 1 1 260px; }\n .bd-stat .k { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--sg-muted, #64748b); }\n .bd-stat .v { font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.25; }\n .bd-stat .s { font-size: 12px; color: var(--sg-muted, #64748b); }\n .bd-bar { display: block; height: 6px; margin-top: 6px; border-radius: 3px; background: var(--sg-header-bg, #f1f5f9); overflow: hidden; }\n .bd-bar > span { display: block; height: 100%; background: linear-gradient(90deg, #3b82f6, #10b981); transition: width 120ms linear; }\n .bd-note { margin: 0; font-size: 13px; color: var(--sg-muted, #64748b); }\n\n .bd-bench { overflow-x: auto; }\n .bd-bench table { border-collapse: collapse; font-size: 13px; font-variant-numeric: tabular-nums; }\n .bd-bench th, .bd-bench td { padding: 4px 12px; border-bottom: 1px solid var(--sg-border, #e2e8f0); text-align: left; white-space: nowrap; }\n .bd-bench th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--sg-muted, #64748b); font-weight: 600; }\n .bd-bench td.num { text-align: right; font-weight: 600; }\n .bd-bench td.dim { color: var(--sg-muted, #64748b); }\n\n .bd-grid { flex: 1; min-height: 320px; display: flex; flex-direction: column; }\n\n /* ---- cell palette (data colours, not chrome) ------------------------------- */\n .bd-grid :global(.bd-id) { color: var(--sg-muted, #64748b); font-variant-numeric: tabular-nums; }\n .bd-grid :global(.bd-muted) { color: var(--sg-muted, #64748b); }\n .bd-grid :global(.bd-name) { font-weight: 600; }\n .bd-grid :global(.bd-total) { font-weight: 700; }\n .bd-grid :global(.bd-stars) { color: #f59e0b; letter-spacing: 1px; }\n .bd-grid :global(.bd-yes) { color: #10b981; font-weight: 700; }\n .bd-grid :global(.bd-no) { color: var(--sg-muted, #94a3b8); }\n .bd-grid :global(.bd-neg) { color: #ef4444; }\n .bd-grid :global(.bd-pos1) { color: #16a34a; }\n .bd-grid :global(.bd-pos2) { color: #15803d; font-weight: 600; }\n .bd-grid :global(.bd-pos3) { color: #047857; font-weight: 700; }\n .bd-grid :global(.bd-chip),\n .bd-grid :global(.bd-pill) { font-weight: 600; font-size: 12px; }\n .bd-grid :global(.bd-c0) { color: #2563eb; }\n .bd-grid :global(.bd-c1) { color: #9333ea; }\n .bd-grid :global(.bd-c2) { color: #db2777; }\n .bd-grid :global(.bd-c3) { color: #ea580c; }\n .bd-grid :global(.bd-c4) { color: #0891b2; }\n .bd-grid :global(.bd-c5) { color: #65a30d; }\n .bd-grid :global(.bd-c6) { color: #ca8a04; }\n .bd-grid :global(.bd-c7) { color: #4f46e5; }\n .bd-grid :global(.bd-s-active) { color: #16a34a; }\n .bd-grid :global(.bd-s-churned) { color: #dc2626; }\n .bd-grid :global(.bd-s-pending) { color: #d97706; }\n .bd-grid :global(.bd-s-vip) { color: #7c3aed; }\n .bd-grid :global(.bd-h0) { background: color-mix(in srgb, #8b5cf6 4%, transparent); }\n .bd-grid :global(.bd-h1) { background: color-mix(in srgb, #8b5cf6 8%, transparent); }\n .bd-grid :global(.bd-h2) { background: color-mix(in srgb, #8b5cf6 12%, transparent); }\n .bd-grid :global(.bd-h3) { background: color-mix(in srgb, #8b5cf6 16%, transparent); }\n .bd-grid :global(.bd-h4) { background: color-mix(in srgb, #8b5cf6 20%, transparent); }\n .bd-grid :global(.bd-h5) { background: color-mix(in srgb, #ec4899 18%, transparent); }\n .bd-grid :global(.bd-h6) { background: color-mix(in srgb, #ec4899 24%, transparent); }\n .bd-grid :global(.bd-h7) { background: color-mix(in srgb, #f97316 24%, transparent); }\n .bd-grid :global(.bd-h8) { background: color-mix(in srgb, #f97316 32%, transparent); }\n .bd-grid :global(.bd-h9) { background: color-mix(in srgb, #ef4444 34%, transparent); }\n\n @media (max-width: 640px) {\n .bd-stat { min-width: calc(50% - 4px); }\n .bd-seg button { padding: 6px 8px; }\n }\n</style>\n"
3065
+ },
3058
3066
  {
3059
3067
  "id": "51-ai-assistant",
3060
3068
  "path": "examples/src/demos/51-ai-assistant.svelte",
@@ -3261,7 +3269,7 @@ export const examples = [
3261
3269
  "title": "Million Rows",
3262
3270
  "category": "Getting Started",
3263
3271
  "blurb": "78. One million rows Row + column virtualization carrying a literal 1,000,000-row dataset with sort, filter, group, scroll, and inline edit all enabled.",
3264
- "source": "<script lang=\"ts\">\n /**\n * 78. One million rows\n * --------------------\n * Row + column virtualization carrying a literal 1,000,000-row dataset\n * with sort, filter, group, scroll, and inline edit all enabled.\n *\n * Generation is chunked through requestAnimationFrame so the UI keeps\n * painting while the heap fills. The row shape is intentionally lean\n * (9 fields) - at 1M rows that's the difference between ~250 MB and\n * an unresponsive tab.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n type GridColumns,\n type SvGridApi,\n } from '@svgrid/grid'\n\n type Status = 'active' | 'pending' | 'inactive'\n\n type MillionRow = {\n id: string\n firstName: string\n lastName: string\n department: string\n country: string\n status: Status\n age: number\n salary: number\n performance: number\n }\n\n // --- Compact pools (kept short on purpose: smaller string-intern table\n // means 1M rows share a tiny set of pointers). ------------------------\n const FIRST = ['Ada','Linus','Grace','Alan','Margaret','Donald','Barbara','John',\n 'Edsger','Niklaus','Anders','Brendan','Yukihiro','Rasmus','Bjarne','Tim',\n 'Guido','Larry','James','Brian','Dennis','Ken','Mary','Susan','Karen','Lisa']\n const LAST = ['Lovelace','Torvalds','Hopper','Turing','Hamilton','Knuth','Liskov',\n 'McCarthy','Dijkstra','Wirth','Hejlsberg','Eich','Matsumoto','Lerdorf',\n 'Stroustrup','van Rossum','Wall','Gosling','Kernighan','Ritchie','Thompson']\n const DEPARTMENTS = ['Engineering','Design','Product','Sales','Support','Operations']\n const COUNTRIES = ['US','UK','DE','FR','JP','BR','IN','AU','CA','NL']\n const STATUSES: readonly Status[] = ['active','pending','inactive'] as const\n\n // --- Deterministic PRNG (Mulberry32) ------------------------------------\n function rng(seed: number) {\n let t = seed >>> 0\n return () => {\n t = (t + 0x6d2b79f5) >>> 0\n let x = t\n x = Math.imul(x ^ (x >>> 15), x | 1)\n x ^= x + Math.imul(x ^ (x >>> 7), x | 61)\n return ((x ^ (x >>> 14)) >>> 0) / 4_294_967_296\n }\n }\n\n const TARGET = 1_000_000\n const CHUNK = 50_000\n /**\n * Row height (px). 1M × 32 = 32M pixels, which exceeds Firefox's\n * element-height cap (~17.9M) and lands at Chrome's edge (~33.5M).\n * 18px keeps the total scroll height at 18M so EVERY row is reachable\n * in every browser. The grid still reads cleanly at this density.\n */\n const ROW_H = 18\n\n let rows = $state.raw<MillionRow[]>([])\n let generated = $state(0)\n let busy = $state(true)\n let buildMs = $state(0)\n\n function buildChunk(rand: () => number, into: MillionRow[], from: number, to: number) {\n const firstLen = FIRST.length\n const lastLen = LAST.length\n const deptLen = DEPARTMENTS.length\n const countryLen = COUNTRIES.length\n const statusLen = STATUSES.length\n for (let i = from; i < to; i++) {\n const dept = DEPARTMENTS[Math.floor(rand() * deptLen)]!\n const country = COUNTRIES[Math.floor(rand() * countryLen)]!\n const status = STATUSES[Math.floor(rand() * statusLen)]!\n into[i] = {\n id: 'r_' + i.toString(36),\n firstName: FIRST[Math.floor(rand() * firstLen)]!,\n lastName: LAST [Math.floor(rand() * lastLen)]!,\n department: dept,\n country,\n status,\n age: 21 + Math.floor(rand() * 45),\n salary: 35_000 + Math.floor(rand() * 165_000),\n performance: Math.floor(rand() * 100),\n }\n }\n }\n\n async function buildAll() {\n busy = true\n generated = 0\n const t0 = performance.now()\n const rand = rng(1337)\n const buf: MillionRow[] = new Array(TARGET)\n let cursor = 0\n while (cursor < TARGET) {\n const next = Math.min(cursor + CHUNK, TARGET)\n buildChunk(rand, buf, cursor, next)\n cursor = next\n generated = cursor\n // Yield to the browser so the progress bar paints and the page stays\n // responsive. Without this the JS thread runs solid for the full build.\n await new Promise<void>((r) => requestAnimationFrame(() => r()))\n }\n rows = buf\n buildMs = Math.round(performance.now() - t0)\n busy = false\n }\n\n $effect(() => {\n if (rows.length === 0 && busy) {\n void buildAll()\n }\n })\n\n // --- Grid configuration -------------------------------------------------\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n })\n\n const columns: GridColumns<MillionRow> = [\n { field: 'id', header: 'ID', editorType: 'text', width: 110, editable: false },\n { field: 'firstName', header: 'First name', editorType: 'text', width: 130 },\n { field: 'lastName', header: 'Last name', editorType: 'text', width: 140 },\n { field: 'department', header: 'Department', editorType: 'list',\n editorOptions: DEPARTMENTS as unknown as ReadonlyArray<string>, width: 140 },\n { field: 'country', header: 'Country', editorType: 'list',\n editorOptions: COUNTRIES as unknown as ReadonlyArray<string>, width: 100 },\n { field: 'status', header: 'Status', editorType: 'list',\n editorOptions: STATUSES as unknown as ReadonlyArray<string>, width: 110 },\n { field: 'age', header: 'Age', editorType: 'number', width: 90 },\n { field: 'salary', header: 'Salary', editorType: 'number', width: 130,\n format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } },\n { field: 'performance',header: 'Performance',editorType: 'number', width: 130 },\n ]\n\n let api = $state<SvGridApi<typeof features, MillionRow> | null>(null)\n let groupBy = $state<string[]>([])\n let editing = $state(true)\n\n function applyGroup(by: string[]) {\n groupBy = by\n api?.setGroupBy(by)\n }\n\n const pct = $derived(Math.min(100, Math.round((generated / TARGET) * 100)))\n const fmt = new Intl.NumberFormat('en-US')\n</script>\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <!-- KPI strip -->\n <div class=\"mr-kpi-strip shrink-0\">\n <div class=\"mr-kpi\">\n <div class=\"mr-kpi-label\">Rows</div>\n <div class=\"mr-kpi-value tabular-nums\">{fmt.format(rows.length || generated)}</div>\n <div class=\"mr-kpi-foot\">target {fmt.format(TARGET)}</div>\n </div>\n <div class=\"mr-kpi\">\n <div class=\"mr-kpi-label\">Columns</div>\n <div class=\"mr-kpi-value tabular-nums\">{columns.length}</div>\n <div class=\"mr-kpi-foot\">column virtualization on</div>\n </div>\n <div class=\"mr-kpi\">\n <div class=\"mr-kpi-label\">Build time</div>\n <div class=\"mr-kpi-value tabular-nums\">{busy ? '…' : buildMs + ' ms'}</div>\n <div class=\"mr-kpi-foot\">chunked, 50k / frame</div>\n </div>\n <div class=\"mr-kpi\">\n <div class=\"mr-kpi-label\">Editing</div>\n <div class={`mr-kpi-value ${editing ? 'mr-up' : 'mr-down'}`}>{editing ? 'ON' : 'off'}</div>\n <div class=\"mr-kpi-foot\">double-click any cell</div>\n </div>\n </div>\n\n <!-- Toolbar -->\n <div class=\"flex flex-wrap items-center gap-2 text-sm shrink-0\">\n <span class=\"font-medium\">Group by:</span>\n <button type=\"button\" onclick={() => applyGroup([])}\n class=\"mr-btn {groupBy.length === 0 ? 'mr-btn-on' : ''}\">None</button>\n <button type=\"button\" onclick={() => applyGroup(['department'])}\n class=\"mr-btn {groupBy.join() === 'department' ? 'mr-btn-on' : ''}\">Department</button>\n <button type=\"button\" onclick={() => applyGroup(['country'])}\n class=\"mr-btn {groupBy.join() === 'country' ? 'mr-btn-on' : ''}\">Country</button>\n <button type=\"button\" onclick={() => applyGroup(['department','country'])}\n class=\"mr-btn {groupBy.join() === 'department,country' ? 'mr-btn-on' : ''}\">Department → Country</button>\n <button type=\"button\" onclick={() => applyGroup(['status'])}\n class=\"mr-btn {groupBy.join() === 'status' ? 'mr-btn-on' : ''}\">Status</button>\n\n <span class=\"mx-3 mr-note\">|</span>\n <label class=\"inline-flex items-center gap-2\">\n <input type=\"checkbox\" bind:checked={editing} />\n <span>Inline editing</span>\n </label>\n\n <span class=\"ml-auto text-xs mr-note\">\n Sort / filter via column headers · scroll the grid - only ~50 rows are mounted in the DOM at any moment.\n Row height is tightened to {ROW_H}px so all 1,000,000 rows stay within the browser's element-height limit.\n </span>\n </div>\n\n {#if busy}\n <div class=\"mr-progress shrink-0\">\n <div class=\"mr-progress-bar\"><div class=\"mr-progress-fill\" style={`width: ${pct}%`}></div></div>\n <div class=\"mr-progress-text tabular-nums\">Generating {fmt.format(generated)} / {fmt.format(TARGET)} · {pct}%</div>\n </div>\n {/if}\n\n {#if rows.length}\n <div class=\"flex-1 min-h-0\">\n <SvGrid\n columnResize\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"cell\"\n showGroupingControls={true}\n enableInlineEditing={editing}\n enableCellSelection={true}\n showRowNumbers={true}\n virtualization={true}\n columnVirtualization={false}\n rowHeight={ROW_H}\n overscan={20}\n rowNumberWidth={92}\n containerHeight=\"100%\"\n fitColumns={true}\n onApiReady={(next) => { api = next; next.setGroupBy(groupBy) }}\n />\n </div>\n {/if}\n</section>\n\n<style>\n /* KPI strip */\n .mr-kpi-strip {\n display: grid;\n grid-template-columns: repeat(4, minmax(0, 1fr));\n gap: 10px;\n }\n .mr-kpi {\n border: 1px solid var(--sg-border, #e2e8f0);\n background: var(--sg-bg, #fff);\n border-radius: 10px;\n padding: 10px 14px;\n display: flex; flex-direction: column; gap: 2px;\n }\n .mr-kpi-label {\n font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase;\n color: var(--sg-muted, #64748b);\n }\n .mr-kpi-value {\n font-size: 22px; font-weight: 700; line-height: 1.1;\n color: var(--sg-fg, #0f172a);\n }\n .mr-kpi-foot { font-size: 11px; color: var(--sg-muted, #94a3b8); }\n .mr-note { color: var(--sg-muted, #64748b); }\n .mr-up { color: #16a34a; }\n .mr-down { color: var(--sg-muted, #64748b); }\n\n /* Toolbar buttons */\n .mr-btn {\n border: 1px solid var(--sg-border, #cbd5e1);\n background: var(--sg-bg, #fff);\n color: var(--sg-fg, #0f172a);\n border-radius: 6px;\n padding: 4px 12px;\n font-size: 13px;\n cursor: pointer;\n }\n .mr-btn:hover { background: var(--sg-row-hover-bg, rgba(148,163,184,0.10)); }\n .mr-btn-on {\n background: var(--sg-accent, #2563eb);\n /* Pair the accent bg with its contrast token, not a hardcoded white - themes\n like shadcn use a light accent in dark mode, where #fff would be invisible. */\n color: var(--sg-on-accent, #fff);\n border-color: transparent;\n font-weight: 600;\n }\n\n /* Progress bar */\n .mr-progress {\n display: flex; align-items: center; gap: 12px;\n border: 1px solid var(--sg-border, #e2e8f0);\n background: var(--sg-bg, #fff);\n border-radius: 8px;\n padding: 10px 14px;\n }\n .mr-progress-bar {\n flex: 1;\n height: 8px;\n background: var(--sg-header-bg, #f1f5f9);\n border-radius: 999px;\n overflow: hidden;\n }\n .mr-progress-fill {\n height: 100%;\n background: var(--sg-accent, #6366f1);\n transition: width 100ms linear;\n }\n .mr-progress-text {\n font-size: 12px;\n color: var(--sg-muted, #64748b);\n white-space: nowrap;\n }\n</style>\n"
3272
+ "source": "<script lang=\"ts\">\n /**\n * 78. One million rows\n * --------------------\n * Row + column virtualization carrying a literal 1,000,000-row dataset\n * with sort, filter, group, scroll, and inline edit all enabled.\n *\n * Generation is chunked through requestAnimationFrame so the UI keeps\n * painting while the heap fills. The row shape is intentionally lean\n * (9 fields) - at 1M rows that's the difference between ~250 MB and\n * an unresponsive tab.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n type GridColumns,\n type SvGridApi,\n } from '@svgrid/grid'\n\n type Status = 'active' | 'pending' | 'inactive'\n\n type MillionRow = {\n id: string\n firstName: string\n lastName: string\n department: string\n country: string\n status: Status\n age: number\n salary: number\n performance: number\n }\n\n // --- Compact pools (kept short on purpose: smaller string-intern table\n // means 1M rows share a tiny set of pointers). ------------------------\n const FIRST = ['Ada','Linus','Grace','Alan','Margaret','Donald','Barbara','John',\n 'Edsger','Niklaus','Anders','Brendan','Yukihiro','Rasmus','Bjarne','Tim',\n 'Guido','Larry','James','Brian','Dennis','Ken','Mary','Susan','Karen','Lisa']\n const LAST = ['Lovelace','Torvalds','Hopper','Turing','Hamilton','Knuth','Liskov',\n 'McCarthy','Dijkstra','Wirth','Hejlsberg','Eich','Matsumoto','Lerdorf',\n 'Stroustrup','van Rossum','Wall','Gosling','Kernighan','Ritchie','Thompson']\n const DEPARTMENTS = ['Engineering','Design','Product','Sales','Support','Operations']\n const COUNTRIES = ['US','UK','DE','FR','JP','BR','IN','AU','CA','NL']\n const STATUSES: readonly Status[] = ['active','pending','inactive'] as const\n\n // --- Deterministic PRNG (Mulberry32) ------------------------------------\n function rng(seed: number) {\n let t = seed >>> 0\n return () => {\n t = (t + 0x6d2b79f5) >>> 0\n let x = t\n x = Math.imul(x ^ (x >>> 15), x | 1)\n x ^= x + Math.imul(x ^ (x >>> 7), x | 61)\n return ((x ^ (x >>> 14)) >>> 0) / 4_294_967_296\n }\n }\n\n const TARGET = 1_000_000\n const CHUNK = 50_000\n /**\n * Row height (px). 1M × 32 = 32M pixels, which exceeds Firefox's\n * element-height cap (~17.9M) and lands at Chrome's edge (~33.5M).\n * 18px keeps the total scroll height at 18M so EVERY row is reachable\n * in every browser. The grid still reads cleanly at this density.\n */\n const ROW_H = 18\n\n let rows = $state.raw<MillionRow[]>([])\n let generated = $state(0)\n let busy = $state(true)\n let buildMs = $state(0)\n\n function buildChunk(rand: () => number, into: MillionRow[], from: number, to: number) {\n const firstLen = FIRST.length\n const lastLen = LAST.length\n const deptLen = DEPARTMENTS.length\n const countryLen = COUNTRIES.length\n const statusLen = STATUSES.length\n for (let i = from; i < to; i++) {\n const dept = DEPARTMENTS[Math.floor(rand() * deptLen)]!\n const country = COUNTRIES[Math.floor(rand() * countryLen)]!\n const status = STATUSES[Math.floor(rand() * statusLen)]!\n into[i] = {\n id: 'r_' + i.toString(36),\n firstName: FIRST[Math.floor(rand() * firstLen)]!,\n lastName: LAST [Math.floor(rand() * lastLen)]!,\n department: dept,\n country,\n status,\n age: 21 + Math.floor(rand() * 45),\n salary: 35_000 + Math.floor(rand() * 165_000),\n performance: Math.floor(rand() * 100),\n }\n }\n }\n\n async function buildAll() {\n busy = true\n generated = 0\n const t0 = performance.now()\n const rand = rng(1337)\n const buf: MillionRow[] = new Array(TARGET)\n let cursor = 0\n while (cursor < TARGET) {\n const next = Math.min(cursor + CHUNK, TARGET)\n buildChunk(rand, buf, cursor, next)\n cursor = next\n generated = cursor\n // Yield to the browser so the progress bar paints and the page stays\n // responsive. Without this the JS thread runs solid for the full build.\n await new Promise<void>((r) => requestAnimationFrame(() => r()))\n }\n rows = buf\n buildMs = Math.round(performance.now() - t0)\n busy = false\n }\n\n $effect(() => {\n if (rows.length === 0 && busy) {\n void buildAll()\n }\n })\n\n // --- Grid configuration -------------------------------------------------\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n })\n\n const columns: GridColumns<MillionRow> = [\n { field: 'id', header: 'ID', editorType: 'text', width: 110, editable: false },\n { field: 'firstName', header: 'First name', editorType: 'text', width: 130 },\n { field: 'lastName', header: 'Last name', editorType: 'text', width: 140 },\n { field: 'department', header: 'Department', editorType: 'list',\n editorOptions: DEPARTMENTS as unknown as ReadonlyArray<string>, width: 140 },\n { field: 'country', header: 'Country', editorType: 'list',\n editorOptions: COUNTRIES as unknown as ReadonlyArray<string>, width: 100 },\n { field: 'status', header: 'Status', editorType: 'list',\n editorOptions: STATUSES as unknown as ReadonlyArray<string>, width: 110 },\n { field: 'age', header: 'Age', editorType: 'number', width: 90 },\n { field: 'salary', header: 'Salary', editorType: 'number', width: 130,\n format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } },\n { field: 'performance',header: 'Performance',editorType: 'number', width: 130 },\n ]\n\n let api = $state<SvGridApi<typeof features, MillionRow> | null>(null)\n let groupBy = $state<string[]>([])\n let editing = $state(true)\n\n function applyGroup(by: string[]) {\n groupBy = by\n api?.setGroupBy(by)\n }\n\n const pct = $derived(Math.min(100, Math.round((generated / TARGET) * 100)))\n const fmt = new Intl.NumberFormat('en-US')\n</script>\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <!-- KPI strip -->\n <div class=\"mr-kpi-strip shrink-0\">\n <div class=\"mr-kpi\">\n <div class=\"mr-kpi-label\">Rows</div>\n <div class=\"mr-kpi-value tabular-nums\">{fmt.format(rows.length || generated)}</div>\n <div class=\"mr-kpi-foot\">target {fmt.format(TARGET)}</div>\n </div>\n <div class=\"mr-kpi\">\n <div class=\"mr-kpi-label\">Columns</div>\n <div class=\"mr-kpi-value tabular-nums\">{columns.length}</div>\n <div class=\"mr-kpi-foot\">column virtualization on</div>\n </div>\n <div class=\"mr-kpi\">\n <div class=\"mr-kpi-label\">Build time</div>\n <div class=\"mr-kpi-value tabular-nums\">{busy ? '…' : buildMs + ' ms'}</div>\n <div class=\"mr-kpi-foot\">chunked, 50k / frame</div>\n </div>\n <div class=\"mr-kpi\">\n <div class=\"mr-kpi-label\">Editing</div>\n <div class={`mr-kpi-value ${editing ? 'mr-up' : 'mr-down'}`}>{editing ? 'ON' : 'off'}</div>\n <div class=\"mr-kpi-foot\">double-click any cell</div>\n </div>\n </div>\n\n <!-- Toolbar -->\n <div class=\"flex flex-wrap items-center gap-2 text-sm shrink-0\">\n <span class=\"font-medium\">Group by:</span>\n <button type=\"button\" onclick={() => applyGroup([])}\n class=\"mr-btn {groupBy.length === 0 ? 'mr-btn-on' : ''}\">None</button>\n <button type=\"button\" onclick={() => applyGroup(['department'])}\n class=\"mr-btn {groupBy.join() === 'department' ? 'mr-btn-on' : ''}\">Department</button>\n <button type=\"button\" onclick={() => applyGroup(['country'])}\n class=\"mr-btn {groupBy.join() === 'country' ? 'mr-btn-on' : ''}\">Country</button>\n <button type=\"button\" onclick={() => applyGroup(['department','country'])}\n class=\"mr-btn {groupBy.join() === 'department,country' ? 'mr-btn-on' : ''}\">Department → Country</button>\n <button type=\"button\" onclick={() => applyGroup(['status'])}\n class=\"mr-btn {groupBy.join() === 'status' ? 'mr-btn-on' : ''}\">Status</button>\n\n <span class=\"mx-3 mr-note\">|</span>\n <label class=\"inline-flex items-center gap-2\">\n <input type=\"checkbox\" bind:checked={editing} />\n <span>Inline editing</span>\n </label>\n\n <span class=\"ml-auto text-xs mr-note\">\n Sort / filter via column headers · scroll the grid - only ~50 rows are mounted in the DOM at any moment.\n Row height is tightened to {ROW_H}px so all 1,000,000 rows stay within the browser's element-height limit.\n </span>\n </div>\n\n {#if busy}\n <div class=\"mr-progress shrink-0\">\n <div class=\"mr-progress-bar\"><div class=\"mr-progress-fill\" style={`width: ${pct}%`}></div></div>\n <div class=\"mr-progress-text tabular-nums\">Generating {fmt.format(generated)} / {fmt.format(TARGET)} · {pct}%</div>\n </div>\n {/if}\n\n {#if rows.length}\n <div class=\"mr-grid flex-1 min-h-0\">\n <SvGrid\n columnResize\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"cell\"\n showGroupingControls={true}\n enableInlineEditing={editing}\n enableCellSelection={true}\n showRowNumbers={true}\n virtualization={true}\n columnVirtualization={false}\n rowHeight={ROW_H}\n overscan={20}\n rowNumberWidth={92}\n containerHeight=\"100%\"\n fitColumns={true}\n onApiReady={(next) => { api = next; next.setGroupBy(groupBy) }}\n />\n </div>\n {/if}\n</section>\n\n<style>\n /* Text that fits the 18 px rows. The cells inherit the page's 16 px type\n on a 24 px line, which needs a 25 px row; a row is exactly its\n rowHeight, so that line would be cramped into 18 px. */\n .mr-grid { font-size: 13px; line-height: 17px; }\n\n /* KPI strip */\n .mr-kpi-strip {\n display: grid;\n grid-template-columns: repeat(4, minmax(0, 1fr));\n gap: 10px;\n }\n .mr-kpi {\n border: 1px solid var(--sg-border, #e2e8f0);\n background: var(--sg-bg, #fff);\n border-radius: 10px;\n padding: 10px 14px;\n display: flex; flex-direction: column; gap: 2px;\n }\n .mr-kpi-label {\n font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase;\n color: var(--sg-muted, #64748b);\n }\n .mr-kpi-value {\n font-size: 22px; font-weight: 700; line-height: 1.1;\n color: var(--sg-fg, #0f172a);\n }\n .mr-kpi-foot { font-size: 11px; color: var(--sg-muted, #94a3b8); }\n .mr-note { color: var(--sg-muted, #64748b); }\n .mr-up { color: #16a34a; }\n .mr-down { color: var(--sg-muted, #64748b); }\n\n /* Toolbar buttons */\n .mr-btn {\n border: 1px solid var(--sg-border, #cbd5e1);\n background: var(--sg-bg, #fff);\n color: var(--sg-fg, #0f172a);\n border-radius: 6px;\n padding: 4px 12px;\n font-size: 13px;\n cursor: pointer;\n }\n .mr-btn:hover { background: var(--sg-row-hover-bg, rgba(148,163,184,0.10)); }\n .mr-btn-on {\n background: var(--sg-accent, #2563eb);\n /* Pair the accent bg with its contrast token, not a hardcoded white - themes\n like shadcn use a light accent in dark mode, where #fff would be invisible. */\n color: var(--sg-on-accent, #fff);\n border-color: transparent;\n font-weight: 600;\n }\n\n /* Progress bar */\n .mr-progress {\n display: flex; align-items: center; gap: 12px;\n border: 1px solid var(--sg-border, #e2e8f0);\n background: var(--sg-bg, #fff);\n border-radius: 8px;\n padding: 10px 14px;\n }\n .mr-progress-bar {\n flex: 1;\n height: 8px;\n background: var(--sg-header-bg, #f1f5f9);\n border-radius: 999px;\n overflow: hidden;\n }\n .mr-progress-fill {\n height: 100%;\n background: var(--sg-accent, #6366f1);\n transition: width 100ms linear;\n }\n .mr-progress-text {\n font-size: 12px;\n color: var(--sg-muted, #64748b);\n white-space: nowrap;\n }\n</style>\n"
3265
3273
  },
3266
3274
  {
3267
3275
  "id": "79-loading-from-rest",
@@ -3670,7 +3678,7 @@ export const docs = [
3670
3678
  "path": "docs/enterprise/studio/code-generation.md",
3671
3679
  "title": "Code generation",
3672
3680
  "section": "Studio",
3673
- "markdown": "# Code generation\n\nStudio generates plain SvelteKit code you own - there is no runtime and no\nlock-in. The same `scaffold()` core powers the [CLI](./cli.md), the\n[AI generator](./ai-generation.md), and the [visual designer](./app-designer.md), so\nall three emit identical files.\n\n![The generated files: schema module, +server.ts API route, and +page.svelte screen, with svgrid:managed markers.](/docs-media/studio-generated-code.png)\n\n## What gets generated\n\nFrom one `EntitySchema`, three files:\n\n| File | Contents |\n| --- | --- |\n| `src/lib/<name>.schema.ts` | The `EntitySchema` literal + a typed row type. |\n| `src/routes/api/<name>/+server.ts` | The API route - `createKitHandlers` over a data source. |\n| `src/routes/<name>/+page.svelte` | The screen - grid + edit panel, wired to the route. |\n\nThe generated page is a full data screen: server-side sort, filter, and global\nsearch; a native pagination footer; a validated create / edit modal;\nmulti-select optimistic delete; and loading / error / empty states. Any\n`relation` (foreign-key) field also gets a searchable **lookup** wired to the\nrelated entity's API route ([Relations](./relations.md)).\n\n## The three files, up close\n\n**1. The schema** is the single source of truth - a plain literal you can edit by\nhand or regenerate:\n\n```ts\n// src/lib/customers.schema.ts\nimport type { EntitySchema } from '@svgrid/enterprise'\n\nexport type CustomersRow = {\n id: string; name: string; email: string; mrr: number; active: boolean\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', format: 'email', required: true },\n { field: 'mrr', type: 'number', min: 0 },\n { field: 'active', type: 'boolean' },\n ],\n}\n```\n\n**2. The API route** is a single `POST` endpoint. `createKitHandlers` speaks one\nJSON wire protocol for read + create + update + delete, so the client needs just\nthe one handler:\n\n```ts\n// src/routes/api/customers/+server.ts\nimport { createInMemoryDataSource, createKitHandlers } from '@svgrid/enterprise'\nimport { customersSchema, type CustomersRow } from '$lib/customers.schema'\n\nconst source = createInMemoryDataSource<CustomersRow>([], customersSchema)\n\nexport const { POST } = createKitHandlers({ schema: customersSchema, source })\n```\n\nSwap `createInMemoryDataSource` for a SQL, Supabase, or REST source and the page\ndoes not change - that is the [ServerDataSource](./data-binding.md) contract at\nwork.\n\n**3. The screen** wires the grid and the edit panel to that route through the\ncontroller - server-side sort / filter / page, the create-edit modal, and\noptimistic delete. It is a normal `+page.svelte` you own; see the\n[Studio live SQL demo](https://svgrid.com/demos/193-studio-live-sql/) for the\nwhole thing running.\n\n## Managed regions & safe regeneration\n\nEvery generated file wraps its body in markers:\n\n```ts\n// svgrid:managed:start\n// Regenerated by SvGrid Studio. Edits inside these markers are overwritten.\n...generated code...\n// svgrid:managed:end\n```\n\nRe-running generation replaces **only** the managed region and preserves\neverything outside it. So you can:\n\n- Add imports, helpers, and layout **outside** the markers - they survive\n regeneration.\n- Re-run `add` after a schema or database change to pull in new columns without\n losing your customizations.\n\nThis idempotent regeneration is what makes the generator safe to keep using,\nrather than a one-shot scaffold you fork away from.\n\n## Choosing the data source\n\nThe generated `+server.ts` backend depends on how you scaffold:\n\n| Scaffold | Backend |\n| --- | --- |\n| `--db <dialect>` | Connected to that driver via `process.env.DATABASE_URL`. |\n| `--sql` | `createSqlDataSource` with an `execute()` stub to fill in. |\n| `--from` (no `--db`) | In-memory, so the screen runs immediately. |\n\nSee [Databases](./databases.md) and [Data binding](./data-binding.md).\n\n## Render mode: SPA or SSR per screen\n\nAn **`ssr`** screen emits idiomatic server-rendered SvelteKit: a\n`+page.server.ts` with a `load` function and form `actions`, URL-driven sort /\nfilter / page state, and progressive enhancement. A **`spa`** screen emits a\nclient page where the browser talks to the API route through the data-source\ncontroller.\n\n**New apps built on a database or a REST API get `ssr` for free.** When you\ngenerate an app - `svgrid-studio init`, the designer's **New app** wizard, or\n`crudAppFromSchemas` - every screen that qualifies starts in `ssr`. You can still\nswitch any screen either way in the [app designer](./app-designer.md).\n\nIn-memory and PGlite apps stay `spa`, on purpose. Those sources are module\nsingletons, so a server-rendered screen would read and write the server's copy of\nthe rows while the app's remaining client screens read the browser's: add a row\non one and the other never sees it. SQL and REST have no such split, because\nevery path goes to the same database or the same remote API.\n\n### How the app is wired\n\nOnce an app has at least one server-rendered screen, the root `src/routes/+layout.ts`\nleaves SvelteKit's own default in place - server rendering on - and each\nclient-only screen opts out in its own `+page.ts`:\n\n```ts\n// src/routes/<screen>/+page.ts\nexport const ssr = false\n```\n\nSo the nav shell, the home page, and the sign-in pages all render on the server,\nand only the screens that fetch in the browser skip it. An app with nothing to\nserver-render keeps the single `export const ssr = false` in the root layout, as\nbefore.\n\nNot every screen shape can emit as SSR. The rules:\n\n- The screen must be entity-bound, without a [code-behind](./code-behind.md)\n companion.\n- Its data source must be `memory` (runs in-process), `sql` (reuses the connected\n `/api` route via `event.fetch`), or `rest` **on an absolute URL** (the server\n calls the remote API directly; a relative URL has no origin to resolve against\n there, so it stays SPA). `supabase` and `pglite` screens stay SPA - PGlite only\n exists in the browser, and a Supabase read carries the signed-in user's token,\n which a server-side call with the anon key would silently drop.\n- **A grid, optionally with a facet panel**, emits as load + form actions (no\n tree data, no scheduler view). The facet panel becomes a plain `GET` form\n whose controls are named for the URL params the `load` reads, so filtering\n works with JavaScript off and every filtered view has a shareable URL.\n- **Read-only block screens** - any mix of chart, pivot, dashboard, KPI, gauge,\n tree, detail, and master-detail - emit as a load-only page.\n- Anything else (boards, calendars, UI component blocks, containers,\n grids with extras) stays SPA.\n\nThe designer only offers the toggle when the screen qualifies; a screen set to\n`ssr` that stops qualifying falls back to the SPA emit.\n\n## Verification\n\nThe AI path compiles the generated page (via the Svelte compiler) before handing\nit back, and the recommended final step everywhere is your project's own check:\n\n```bash\nnpx svelte-check\n```\n\n## See also\n\n- [The Studio CLI](./cli.md) · [AI generation](./ai-generation.md) · [Visual app designer](./app-designer.md)\n"
3681
+ "markdown": "# Code generation\n\nStudio generates plain SvelteKit code you own - there is no runtime and no\nlock-in. The same `scaffold()` core powers the [CLI](./cli.md), the\n[AI generator](./ai-generation.md), and the [visual designer](./app-designer.md), so\nall three emit identical files.\n\n![The generated files: schema module, +server.ts API route, and +page.svelte screen, with svgrid:managed markers.](/docs-media/studio-generated-code.png)\n\n## What gets generated\n\nFrom one `EntitySchema`, three files:\n\n| File | Contents |\n| --- | --- |\n| `src/lib/<name>.schema.ts` | The `EntitySchema` literal + a typed row type. |\n| `src/routes/api/<name>/+server.ts` | The API route - `createKitHandlers` over a data source. |\n| `src/routes/<name>/+page.svelte` | The screen - grid + edit panel, wired to the route. |\n\nThe generated page is a full data screen: server-side sort, filter, and global\nsearch; a native pagination footer; a validated create / edit modal;\nmulti-select optimistic delete; and loading / error / empty states. Any\n`relation` (foreign-key) field also gets a searchable **lookup** wired to the\nrelated entity's API route ([Relations](./relations.md)).\n\n## The three files, up close\n\n**1. The schema** is the single source of truth - a plain literal you can edit by\nhand or regenerate:\n\n```ts\n// src/lib/customers.schema.ts\nimport type { EntitySchema } from '@svgrid/enterprise'\n\nexport type CustomersRow = {\n id: string; name: string; email: string; mrr: number; active: boolean\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', format: 'email', required: true },\n { field: 'mrr', type: 'number', min: 0 },\n { field: 'active', type: 'boolean' },\n ],\n}\n```\n\n**2. The API route** is a single `POST` endpoint. `createKitHandlers` speaks one\nJSON wire protocol for read + create + update + delete, so the client needs just\nthe one handler:\n\n```ts\n// src/routes/api/customers/+server.ts\nimport { createInMemoryDataSource, createKitHandlers } from '@svgrid/enterprise'\nimport { customersSchema, type CustomersRow } from '$lib/customers.schema'\n\nconst source = createInMemoryDataSource<CustomersRow>([], customersSchema)\n\nexport const { POST } = createKitHandlers({ schema: customersSchema, source })\n```\n\nSwap `createInMemoryDataSource` for a SQL, Supabase, or REST source and the page\ndoes not change - that is the [ServerDataSource](./data-binding.md) contract at\nwork.\n\n**3. The screen** wires the grid and the edit panel to that route through the\ncontroller - server-side sort / filter / page, the create-edit modal, and\noptimistic delete. It is a normal `+page.svelte` you own; see the\n[Studio live SQL demo](https://svgrid.com/demos/193-studio-live-sql/) for the\nwhole thing running.\n\n## Managed regions & safe regeneration\n\nEvery generated file wraps its body in markers:\n\n```ts\n// svgrid:managed:start\n// Regenerated by SvGrid Studio. Edits inside these markers are overwritten.\n...generated code...\n// svgrid:managed:end\n```\n\nRe-running generation replaces **only** the managed region and preserves\neverything outside it. So you can:\n\n- Add imports, helpers, and layout **outside** the markers - they survive\n regeneration.\n- Re-run `add` after a schema or database change to pull in new columns without\n losing your customizations.\n\nThis idempotent regeneration is what makes the generator safe to keep using,\nrather than a one-shot scaffold you fork away from.\n\n## Choosing the data source\n\nThe generated `+server.ts` backend depends on how you scaffold:\n\n| Scaffold | Backend |\n| --- | --- |\n| `--db <dialect>` | Connected to that driver via `process.env.DATABASE_URL`. |\n| `--sql` | `createSqlDataSource` with an `execute()` stub to fill in. |\n| `--from` (no `--db`) | In-memory, so the screen runs immediately. |\n\nSee [Databases](./databases.md) and [Data binding](./data-binding.md).\n\n## Render mode: SPA or SSR per screen\n\nAn **`ssr`** screen emits idiomatic server-rendered SvelteKit: a\n`+page.server.ts` with a `load` function and form `actions`, URL-driven sort /\nfilter / page state, and progressive enhancement. A **`spa`** screen emits a\nclient page where the browser talks to the API route through the data-source\ncontroller.\n\n**New apps get `ssr` wherever a screen qualifies.** When you generate an app -\n`svgrid-studio init`, the designer's **New app** wizard, or `crudAppFromSchemas` -\nevery qualifying screen on a SQL, REST, or in-memory entity starts in `ssr`. You\ncan still switch any screen either way in the [app designer](./app-designer.md),\nand a screen you set to `spa` stays `spa`.\n\nIn-memory data is held once, on the server. The seeded rows live in\n`src/lib/server/store.ts`, and every screen reaches them through the entity's\n`/api/<entity>` route, the same way a SQL entity does. So a server-rendered screen\nand a client screen read and write the same rows, and the route's validation,\naccess rules, and triggers apply to both. See\n[In-memory](./in-memory.md#in-a-studio-app) for what that means in a deployed app.\n\nPGlite apps stay `spa`: PGlite keeps its rows in the browser's IndexedDB, which\nthe server cannot read.\n\n### How the app is wired\n\nOnce an app has at least one server-rendered screen, the root `src/routes/+layout.ts`\nleaves SvelteKit's own default in place - server rendering on - and each\nclient-only screen opts out in its own `+page.ts`:\n\n```ts\n// src/routes/<screen>/+page.ts\nexport const ssr = false\n```\n\nSo the nav shell, the home page, and the sign-in pages all render on the server,\nand only the screens that fetch in the browser skip it. An app with nothing to\nserver-render keeps the single `export const ssr = false` in the root layout, as\nbefore.\n\nNot every screen shape can emit as SSR. The rules:\n\n- The screen must be entity-bound, without a [code-behind](./code-behind.md)\n companion.\n- Its data source must be `memory` or `sql` (both reuse the entity's `/api` route\n via `event.fetch`), or `rest` **on an absolute URL** (the server\n calls the remote API directly; a relative URL has no origin to resolve against\n there, so it stays SPA). `supabase` and `pglite` screens stay SPA - PGlite only\n exists in the browser, and a Supabase read carries the signed-in user's token,\n which a server-side call with the anon key would silently drop.\n- **A grid, optionally with a facet panel**, emits as load + form actions (no\n tree data, no scheduler view). The facet panel becomes a plain `GET` form\n whose controls are named for the URL params the `load` reads, so filtering\n works with JavaScript off and every filtered view has a shareable URL.\n- **Read-only block screens** - any mix of chart, pivot, dashboard, KPI, gauge,\n tree, detail, and master-detail - emit as a load-only page.\n- Anything else (boards, calendars, UI component blocks, containers,\n grids with extras) stays SPA.\n\nThe designer only offers the toggle when the screen qualifies; a screen set to\n`ssr` that stops qualifying falls back to the SPA emit.\n\n## Verification\n\nThe AI path compiles the generated page (via the Svelte compiler) before handing\nit back, and the recommended final step everywhere is your project's own check:\n\n```bash\nnpx svelte-check\n```\n\n## See also\n\n- [The Studio CLI](./cli.md) · [AI generation](./ai-generation.md) · [Visual app designer](./app-designer.md)\n"
3674
3682
  },
3675
3683
  {
3676
3684
  "slug": "enterprise/studio/concepts",
@@ -3754,7 +3762,7 @@ export const docs = [
3754
3762
  "path": "docs/enterprise/studio/in-memory.md",
3755
3763
  "title": "In-memory",
3756
3764
  "section": "Studio",
3757
- "markdown": "# In-memory\n\nThe in-memory source runs a full CRUD data screen against a plain array - no\ndatabase, no API. It applies the grid's filter, global search, sort, and paging\nin memory, and supports create / update / delete. Ideal for prototyping a schema,\ntests, demos, static reference data, or seeding a screen before the backend\nexists.\n\n![A full CRUD screen sends filter, search, sort, page, and create/update/delete through the in-memory source, which applies them to a plain array in memory - no database, no API.](/docs-media/studio-in-memory.svg)\n\nIt is the default backend the CLI emits when you scaffold from a\n[Drizzle schema](./drizzle.md) without `--db`, so the screen runs the moment you\n`npm run dev`.\n\n## Usage\n\n```ts\nimport { createServerDataSource } from '@svgrid/grid'\nimport { createInMemoryDataSource } from '@svgrid/enterprise'\nimport { customersSchema, type CustomersRow } from '$lib/customers.schema'\n\nconst seed: CustomersRow[] = [\n { id: '1', name: 'Ada Lovelace', email: 'ada@analytic.io', mrr: 1200, active: true },\n { id: '2', name: 'Alan Turing', email: 'alan@bletchley.uk', mrr: 240, active: true },\n // ...\n]\n\nconst source = createInMemoryDataSource(seed, customersSchema)\n\nconst controller = createServerDataSource(source, {\n pageSize: 25,\n optimistic: true,\n getRowId: (r) => r.id,\n onChange: (s) => (view = s),\n})\ncontroller.refresh()\n```\n\n`createInMemoryDataSource(rows, schema)` returns a full `ServerDataSource`\n(read + create + update + delete). Everything the grid asks for - filter\noperators, facet selections, global search, multi-column sort, paging - is\napplied to the array, exactly like a real backend, so you can build the whole\nscreen and swap in a database later without changing the UI.\n\n## Server route\n\nTo expose it behind a SvelteKit API route (as the generated code does):\n\n```ts\n// src/routes/api/customers/+server.ts\nimport { createInMemoryDataSource, createKitHandlers } from '@svgrid/enterprise'\nimport { customersSchema, type CustomersRow } from '$lib/customers.schema'\n\nconst source = createInMemoryDataSource<CustomersRow>([], customersSchema)\nexport const { POST } = createKitHandlers({ schema: customersSchema, source })\n```\n\n## Reseeding and fixtures\n\n`createInMemoryDataSource(rows, schema)` copies the rows in - the array you pass\nis a seed, not a live binding. Load it from a JSON fixture to keep sample data out\nof your components:\n\n```ts\nimport seed from '$lib/fixtures/customers.json'\nconst source = createInMemoryDataSource(seed, customersSchema)\n```\n\nBecause the data lives inside the source, a fresh `createInMemoryDataSource(...)`\nis a clean slate - handy between tests, or behind a \"reset demo data\" button.\n\n## In tests\n\nThe source is a plain object implementing `ServerDataSource`, so you can exercise\nthe whole screen's data behaviour directly - no DOM, no database:\n\n```ts\nimport { describe, it, expect } from 'vitest'\nimport { createInMemoryDataSource } from '@svgrid/enterprise'\nimport { customersSchema, type CustomersRow } from '$lib/customers.schema'\n\ndescribe('customers screen data', () => {\n it('filters, sorts, creates, and deletes', async () => {\n const source = createInMemoryDataSource<CustomersRow>([\n { id: '1', name: 'Ada Lovelace', email: 'ada@analytic.io', mrr: 1200, active: true },\n { id: '2', name: 'Alan Turing', email: 'alan@bletchley.uk', mrr: 240, active: false },\n ], customersSchema)\n\n // read a page exactly the way the grid asks (see Data binding)\n const page = await source.getRows({\n startRow: 0, endRow: 25, pageIndex: 0, pageSize: 25,\n sortModel: [{ id: 'mrr', desc: true }],\n filterModel: { columns: { active: { operator: 'equals', value: 'true' } } },\n })\n expect(page.rowCount).toBe(1)\n expect(page.rows[0]!.name).toBe('Ada Lovelace')\n\n const created = await source.createRow!({ name: 'Grace Hopper', email: 'grace@navy.mil', mrr: 900, active: true })\n await source.deleteRow!(created.id)\n })\n})\n```\n\nIt is the same `ServerRequest` the grid sends at runtime, so a test that passes\nhere is a screen that behaves.\n\n## When to move on\n\nThe in-memory source keeps its data in the server process, so it resets on\nrestart and is not shared across instances. When you are ready for persistence,\nswitch to a [database](./databases.md) or a [custom source](./rest-api.md) - the\nschema, grid, and form stay exactly the same.\n\n## See also\n\n- [Data binding](./data-binding.md) · [Databases](./databases.md) · [Drizzle schema](./drizzle.md)\n"
3765
+ "markdown": "# In-memory\n\nThe in-memory source runs a full CRUD data screen against a plain array - no\ndatabase, no API. It applies the grid's filter, global search, sort, and paging\nin memory, and supports create / update / delete. Ideal for prototyping a schema,\ntests, demos, static reference data, or seeding a screen before the backend\nexists.\n\n![A full CRUD screen sends filter, search, sort, page, and create/update/delete through the in-memory source, which applies them to a plain array in memory - no database, no API.](/docs-media/studio-in-memory.svg)\n\nIt is the default backend the CLI emits when you scaffold from a\n[Drizzle schema](./drizzle.md) without `--db`, so the screen runs the moment you\n`npm run dev`.\n\n## Usage\n\n```ts\nimport { createServerDataSource } from '@svgrid/grid'\nimport { createInMemoryDataSource } from '@svgrid/enterprise'\nimport { customersSchema, type CustomersRow } from '$lib/customers.schema'\n\nconst seed: CustomersRow[] = [\n { id: '1', name: 'Ada Lovelace', email: 'ada@analytic.io', mrr: 1200, active: true },\n { id: '2', name: 'Alan Turing', email: 'alan@bletchley.uk', mrr: 240, active: true },\n // ...\n]\n\nconst source = createInMemoryDataSource(seed, customersSchema)\n\nconst controller = createServerDataSource(source, {\n pageSize: 25,\n optimistic: true,\n getRowId: (r) => r.id,\n onChange: (s) => (view = s),\n})\ncontroller.refresh()\n```\n\n`createInMemoryDataSource(rows, schema)` returns a full `ServerDataSource`\n(read + create + update + delete). Everything the grid asks for - filter\noperators, facet selections, global search, multi-column sort, paging - is\napplied to the array, exactly like a real backend, so you can build the whole\nscreen and swap in a database later without changing the UI.\n\n## Server route\n\nTo expose it behind a SvelteKit API route (as the generated code does):\n\n```ts\n// src/routes/api/customers/+server.ts\nimport { createInMemoryDataSource, createKitHandlers } from '@svgrid/enterprise'\nimport { customersSchema, type CustomersRow } from '$lib/customers.schema'\n\nconst source = createInMemoryDataSource<CustomersRow>([], customersSchema)\nexport const { POST } = createKitHandlers({ schema: customersSchema, source })\n```\n\n## Reseeding and fixtures\n\n`createInMemoryDataSource(rows, schema)` copies the rows in - the array you pass\nis a seed, not a live binding. Load it from a JSON fixture to keep sample data out\nof your components:\n\n```ts\nimport seed from '$lib/fixtures/customers.json'\nconst source = createInMemoryDataSource(seed, customersSchema)\n```\n\nBecause the data lives inside the source, a fresh `createInMemoryDataSource(...)`\nis a clean slate - handy between tests, or behind a \"reset demo data\" button.\n\n## In tests\n\nThe source is a plain object implementing `ServerDataSource`, so you can exercise\nthe whole screen's data behaviour directly - no DOM, no database:\n\n```ts\nimport { describe, it, expect } from 'vitest'\nimport { createInMemoryDataSource } from '@svgrid/enterprise'\nimport { customersSchema, type CustomersRow } from '$lib/customers.schema'\n\ndescribe('customers screen data', () => {\n it('filters, sorts, creates, and deletes', async () => {\n const source = createInMemoryDataSource<CustomersRow>([\n { id: '1', name: 'Ada Lovelace', email: 'ada@analytic.io', mrr: 1200, active: true },\n { id: '2', name: 'Alan Turing', email: 'alan@bletchley.uk', mrr: 240, active: false },\n ], customersSchema)\n\n // read a page exactly the way the grid asks (see Data binding)\n const page = await source.getRows({\n startRow: 0, endRow: 25, pageIndex: 0, pageSize: 25,\n sortModel: [{ id: 'mrr', desc: true }],\n filterModel: { columns: { active: { operator: 'equals', value: 'true' } } },\n })\n expect(page.rowCount).toBe(1)\n expect(page.rows[0]!.name).toBe('Ada Lovelace')\n\n const created = await source.createRow!({ name: 'Grace Hopper', email: 'grace@navy.mil', mrr: 900, active: true })\n await source.deleteRow!(created.id)\n })\n})\n```\n\nIt is the same `ServerRequest` the grid sends at runtime, so a test that passes\nhere is a screen that behaves.\n\n## In a Studio app\n\nA Studio-generated app holds its in-memory rows on the server. Three files carry\nit:\n\n- `src/lib/server/store.ts` - the seeded rows of every in-memory entity, one\n `createInMemoryDataSource` each. Only server code imports it, so the seed data\n stays out of the browser bundle.\n- `src/routes/api/<entity>/+server.ts` - `createKitHandlers` over that store,\n with the same validation, access rules, triggers, and audit hook a SQL route\n gets.\n- `src/lib/data.ts` - the browser side: a `createKitDataSource` client per\n entity, pointed at its route.\n\nServer-rendered screens read through the same route (with SvelteKit's\n`event.fetch`), so every screen sees the same rows whichever way it renders.\nThe server also assigns ids: a create that arrives with no id, or with one that\nis already taken, gets the next free one, so two open tabs cannot hand out the\nsame id.\n\nWhat that means once the app is deployed:\n\n- Everyone using the app shares one copy of the data. An edit one person makes\n is what the next person sees.\n- A restart, a redeploy, or a dev-server reload of `store.ts` puts the seed rows\n back.\n- On a serverless host each instance holds its own copy, so two requests can\n land on different data.\n\n## When to move on\n\nThe in-memory source keeps its data in the server process, so it resets on\nrestart and is not shared across instances. When you are ready for persistence,\nswitch to a [database](./databases.md) or a [custom source](./rest-api.md) - the\nschema, grid, and form stay exactly the same.\n\n## See also\n\n- [Data binding](./data-binding.md) · [Databases](./databases.md) · [Drizzle schema](./drizzle.md)\n"
3758
3766
  },
3759
3767
  {
3760
3768
  "slug": "enterprise/studio/launch",
@@ -3964,7 +3972,7 @@ export const docs = [
3964
3972
  "path": "docs/getting-started/5-theme-and-density.md",
3965
3973
  "title": "5. Theme and density",
3966
3974
  "section": "Getting started",
3967
- "markdown": "# 5. Theme and density\n\n> Step 5 of 6 · [← Features](./4-features.md) · [Next: Going to production →](./6-going-to-production.md)\n\n## Start with a preset\n\nBefore hand-writing any tokens: 20 design-system presets ship with the package,\neach a single stylesheet with a full light + dark palette. One import re-themes\nthe whole grid.\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 130 },\n { field: 'age', header: 'Age', width: 80 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```ts\nimport '@svgrid/grid/themes/shadcn.css'\n```\n\nAvailable: `ember` (SvGrid's own look), `shadcn`, `tailwind`, `material`,\n`fluent`, `carbon`, `antd`, `bootstrap`, `atlassian`, `salesforce`, `sap`,\n`github`, `linear`, `notion`, `vercel`, `excel`, `nord`, `dracula`,\n`catppuccin`, `ag-alpine`.\n\nEach preset defines every token the grid and the UI components read - including\nthe semantic status colors (`--sg-danger`, `--sg-success`, `--sg-warning`,\n`--sg-info`) and the focus ring, which follows the preset's accent. Presets\nflip with `data-theme=\"dark\"` automatically (see below).\n\nOverride individual tokens after the import to adjust a preset, or skip presets\nentirely and declare the tokens yourself - that is the rest of this page.\n\n## Declaring tokens yourself\n\nThe render component (`<SvGrid>`) ships its own scoped styles. You\nre-theme it by declaring `--sg-*` CSS custom properties at any level\nabove the grid - `:root` for the whole app, a wrapper `<div>` for one\ninstance, or directly on the `<SvGrid>` element itself.\n\n![The --sg-* CSS custom properties declared at :root, a wrapper div, or the SvGrid element cascade into the grid, controlling light and dark themes and comfortable versus compact row density.](/docs-media/gs-theming.svg)\n\n## Token surface\n\nThe 20-odd tokens the renderer reads:\n\n| Token | What it paints |\n| -------------------------------- | ------------------------------------------- |\n| `--sg-bg` | Cell background |\n| `--sg-fg` | Cell text |\n| `--sg-muted` | Secondary text (footers, subtitles) |\n| `--sg-border` | Cell + header borders |\n| `--sg-header-bg` / `--sg-header-fg` | Header row |\n| `--sg-row-alt-bg` | Zebra rows |\n| `--sg-row-hover-bg` | Row + cell hover |\n| `--sg-selection-bg` | Selected cell / row tint |\n| `--sg-accent` | Sort indicator, focus ring, primary buttons |\n| `--sg-focus-ring` | Keyboard focus outline |\n| `--sg-input-bg` / `--sg-input-border` | Inline editor + filter inputs |\n| `--sg-pill-active` / `-fg` | \"Active\" status pills |\n| `--sg-pill-pending` / `-fg` | \"Pending\" status pills |\n| `--sg-pill-inactive` / `-fg` | \"Inactive\" status pills |\n| `--sg-scrollbar-*` (10 tokens) | Custom-painted scrollbars |\n\n## Light + dark via `data-theme`\n\nThe gallery flips themes by writing `dark` or `light` to\n`html[data-theme]`. Every token redeclares under that selector:\n\n```css\n:root {\n --sg-bg: #ffffff;\n --sg-fg: #0f172a;\n --sg-border: #e2e8f0;\n --sg-header-bg: #f1f5f9;\n --sg-row-alt-bg: #f8fafc;\n --sg-row-hover-bg: #eef2ff;\n --sg-accent: #2563eb;\n}\n\nhtml[data-theme='dark'] {\n --sg-bg: #0f172a;\n --sg-fg: #f1f5f9;\n --sg-border: #334155;\n --sg-header-bg: #1e2433;\n --sg-row-alt-bg: #1b2230;\n --sg-row-hover-bg: #232b3c;\n --sg-accent: #3b82f6;\n color-scheme: dark;\n}\n```\n\nToggling is one line in the app shell:\n\n```svelte {runnable}\n<script lang=\"ts\">\n let theme = $state<'light' | 'dark'>('dark')\n $effect(() => document.documentElement.setAttribute('data-theme', theme))\n</script>\n\n<button onclick={() => (theme = theme === 'dark' ? 'light' : 'dark')}>\n Toggle theme\n</button>\n```\n\n## Per-instance theming\n\nBecause the tokens are plain custom properties they cascade. To style a\nsingle grid, wrap it in a `<div>` that sets its own values:\n\n```svelte\n<div style=\"--sg-bg: #fff8f0; --sg-accent: #db2777;\">\n <SvGrid {data} {columns} features={features} />\n</div>\n```\n\nThe [`10-custom-cells-and-themes`](../../examples/src/demos/10-custom-cells-and-themes.svelte)\ndemo applies three full palettes (light / dark / high-contrast) this way.\n\n<div data-docs-demo=\"10-custom-cells-and-themes\" data-height=\"520\"></div>\n\n## Density\n\nDensity is a **prop, not a token**. The virtualizer has to know each\nrow's height as a number before it can position rows, so it cannot be\nresolved from CSS - the grid writes the height as an inline style on\nevery row, which would override a stylesheet rule anyway.\n\nSet `rowHeight` on `<SvGrid>`. Numeric, in pixels; drives the row\nheight and the active-cell hit box. The default is `30`.\n\n```svelte\n<SvGrid {data} {columns} features={features} rowHeight={28} />\n```\n\nCommon steps: `28` compact, `30` normal, `46`-`48` comfortable. For\nrows whose height depends on their content, set `autoRowHeight`\ninstead and the grid measures each row.\n\nA user-facing \"density selector\" is half a dozen lines:\n\n```svelte\n<script lang=\"ts\">\n let density = $state<'compact' | 'normal' | 'comfortable'>('normal')\n const height = $derived(\n density === 'compact' ? 28 : density === 'comfortable' ? 48 : 30,\n )\n</script>\n\n<select bind:value={density}>\n <option value=\"compact\">Compact</option>\n <option value=\"normal\">Normal</option>\n <option value=\"comfortable\">Comfortable</option>\n</select>\n\n<SvGrid {data} {columns} features={features} rowHeight={height} />\n```\n\n## Sizing the grid\n\nThe wrapper renders inside whatever container you give it. The\n`containerHeight` prop sets the scrollable shell height:\n\n```svelte\n<!-- Numeric: px -->\n<SvGrid {data} {columns} features={features} containerHeight={520} />\n\n<!-- String: passed through to CSS -->\n<SvGrid {data} {columns} features={features} containerHeight=\"100%\" />\n<SvGrid {data} {columns} features={features} containerHeight=\"auto\" />\n```\n\nFor a flex-grow layout the canonical recipe is:\n\n```svelte\n<div class=\"flex flex-col h-screen\">\n <header>…</header>\n <div class=\"flex-1 min-h-0\">\n <SvGrid {data} {columns} features={features} containerHeight=\"100%\" />\n </div>\n</div>\n```\n\nThe `min-h-0` is the bit that bites. Flex children default to\n`min-height: auto`, which prevents the inner scroll container from\nshrinking, which makes the whole page scroll instead of the grid.\n\n## Full Tailwind integration\n\nIf your app uses Tailwind, see [Tailwind integration](../help/tailwind.md)\nfor: install + PostCSS config, `@custom-variant` so the `dark:`\nmodifier follows `data-theme`, the override hooks for the stable\n`.sv-grid-*` class names, and the anti-patterns (don't `@apply` inside\ngrid selectors, don't put utility classes on grid children, don't\nfight column widths in CSS).\n\n## Try it\n\nThere is no `density` prop - row height is the density control, and it is one\nnumber. Compact for a screen someone scans all day, comfortable for one they\nread.\n\n```svelte {runnable}\n<SvGrid data={people} {columns} rowHeight={28} showRowNumbers />\n\n<SvGrid data={people} {columns} rowHeight={48} showRowNumbers />\n```\n\n## Re-theming with tokens\n\nThe grid reads `--sg-*` custom properties, so a theme is a block of variables on\nany ancestor - no theme file, no build step.\n\n```svelte {runnable}\n<div class=\"midnight\">\n <SvGrid data={people} {columns} sortable />\n</div>\n\n<style>\n .midnight {\n --sg-bg: #0f172a;\n --sg-fg: #e2e8f0;\n --sg-border: #1e293b;\n --sg-header-bg: #111c33;\n --sg-header-fg: #93c5fd;\n --sg-accent: #38bdf8;\n --sg-row-hover: #16233d;\n }\n</style>\n```\n\n<!-- tutorial:learn-7-theme -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-7-theme\" data-docs-tutorial=\"learn-7-theme\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-7-theme.mp4\" poster=\"/tutorials/learn-7-theme.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 7: themes, dark mode and density, 113 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-7-theme.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-7-theme.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 7: themes, dark mode and density</strong> (1:53, silent). <a href=\"https://www.youtube.com/watch?v=yhJMpkQ0Kww\" rel=\"noopener\">Watch with narration on YouTube</a></figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>A grid that looks like a grid library is a grid that never ships. Theming here is CSS custom properties, not utility classes and not a config object.</p>\n<p>Twenty presets ship with the package, and importing one stylesheet is the whole setup. What a preset sets is a list of custom properties, which means you can set them yourself when one is close but not right. Same five people as before.</p>\n<p>A wrapper, and a handful of properties on it. Background, text, border, the header colours, the accent, the hover. They cascade like any other custom property, so this theme is scoped to this one grid rather than the page.</p>\n<p>The whole file, so nothing is hidden: the rows, the columns, a wrapper around the grid, and the properties on that wrapper.</p>\n<p>Here is that file running. Nine declarations, and nothing about the grid was configured to accept them.</p>\n<p>Density is separate from colour, and it is the setting people actually argue about. Row height is a prop, so the compact table and the comfortable one are the same component with one number changed.</p>\n<p>Dark mode is not a second stylesheet you maintain. Every preset declares a light palette and a dark one, and an attribute on the document element picks which is live. Every surface follows, including the parts you never styled.</p>\n<p>The density control here is the same row height prop behind a dropdown, which is what it usually becomes: a preference you save per user rather than a decision you make once for everybody.</p>\n<p>The theme builder on the site edits these tokens live and hands you the CSS at the end. Every property is listed on the page below. See you in lesson eight.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-7-theme -->\n"
3975
+ "markdown": "# 5. Theme and density\n\n> Step 5 of 6 · [← Features](./4-features.md) · [Next: Going to production →](./6-going-to-production.md)\n\n## Start with a preset\n\nBefore hand-writing any tokens: 20 design-system presets ship with the package,\neach a single stylesheet with a full light + dark palette. One import re-themes\nthe whole grid.\n\n> Designing in Figma? The [SvGrid Design Kit](https://www.figma.com/community/file/1689644521333526144)\n> has the Ember and shadcn/ui presets as variable modes, with every color named\n> after its `--sg-*` token. See [Figma design kit](../help/figma.md).\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 130 },\n { field: 'age', header: 'Age', width: 80 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```ts\nimport '@svgrid/grid/themes/shadcn.css'\n```\n\nAvailable: `ember` (SvGrid's own look), `shadcn`, `tailwind`, `material`,\n`fluent`, `carbon`, `antd`, `bootstrap`, `atlassian`, `salesforce`, `sap`,\n`github`, `linear`, `notion`, `vercel`, `excel`, `nord`, `dracula`,\n`catppuccin`, `ag-alpine`.\n\nEach preset defines every token the grid and the UI components read - including\nthe semantic status colors (`--sg-danger`, `--sg-success`, `--sg-warning`,\n`--sg-info`) and the focus ring, which follows the preset's accent. Presets\nflip with `data-theme=\"dark\"` automatically (see below).\n\nOverride individual tokens after the import to adjust a preset, or skip presets\nentirely and declare the tokens yourself - that is the rest of this page.\n\n## Declaring tokens yourself\n\nThe render component (`<SvGrid>`) ships its own scoped styles. You\nre-theme it by declaring `--sg-*` CSS custom properties at any level\nabove the grid - `:root` for the whole app, a wrapper `<div>` for one\ninstance, or directly on the `<SvGrid>` element itself.\n\n![The --sg-* CSS custom properties declared at :root, a wrapper div, or the SvGrid element cascade into the grid, controlling light and dark themes and comfortable versus compact row density.](/docs-media/gs-theming.svg)\n\n## Token surface\n\nThe 20-odd tokens the renderer reads:\n\n| Token | What it paints |\n| -------------------------------- | ------------------------------------------- |\n| `--sg-bg` | Cell background |\n| `--sg-fg` | Cell text |\n| `--sg-muted` | Secondary text (footers, subtitles) |\n| `--sg-border` | Cell + header borders |\n| `--sg-header-bg` / `--sg-header-fg` | Header row |\n| `--sg-row-alt-bg` | Zebra rows |\n| `--sg-row-hover-bg` | Row + cell hover |\n| `--sg-selection-bg` | Selected cell / row tint |\n| `--sg-accent` | Sort indicator, focus ring, primary buttons |\n| `--sg-focus-ring` | Keyboard focus outline |\n| `--sg-input-bg` / `--sg-input-border` | Inline editor + filter inputs |\n| `--sg-pill-active` / `-fg` | \"Active\" status pills |\n| `--sg-pill-pending` / `-fg` | \"Pending\" status pills |\n| `--sg-pill-inactive` / `-fg` | \"Inactive\" status pills |\n| `--sg-scrollbar-*` (10 tokens) | Custom-painted scrollbars |\n\n## Light + dark via `data-theme`\n\nThe gallery flips themes by writing `dark` or `light` to\n`html[data-theme]`. Every token redeclares under that selector:\n\n```css\n:root {\n --sg-bg: #ffffff;\n --sg-fg: #0f172a;\n --sg-border: #e2e8f0;\n --sg-header-bg: #f1f5f9;\n --sg-row-alt-bg: #f8fafc;\n --sg-row-hover-bg: #eef2ff;\n --sg-accent: #2563eb;\n}\n\nhtml[data-theme='dark'] {\n --sg-bg: #0f172a;\n --sg-fg: #f1f5f9;\n --sg-border: #334155;\n --sg-header-bg: #1e2433;\n --sg-row-alt-bg: #1b2230;\n --sg-row-hover-bg: #232b3c;\n --sg-accent: #3b82f6;\n color-scheme: dark;\n}\n```\n\nToggling is one line in the app shell:\n\n```svelte {runnable}\n<script lang=\"ts\">\n let theme = $state<'light' | 'dark'>('dark')\n $effect(() => document.documentElement.setAttribute('data-theme', theme))\n</script>\n\n<button onclick={() => (theme = theme === 'dark' ? 'light' : 'dark')}>\n Toggle theme\n</button>\n```\n\n## Per-instance theming\n\nBecause the tokens are plain custom properties they cascade. To style a\nsingle grid, wrap it in a `<div>` that sets its own values:\n\n```svelte\n<div style=\"--sg-bg: #fff8f0; --sg-accent: #db2777;\">\n <SvGrid {data} {columns} features={features} />\n</div>\n```\n\nThe [`10-custom-cells-and-themes`](../../examples/src/demos/10-custom-cells-and-themes.svelte)\ndemo applies three full palettes (light / dark / high-contrast) this way.\n\n<div data-docs-demo=\"10-custom-cells-and-themes\" data-height=\"520\"></div>\n\n## Density\n\nDensity is a **prop, not a token**. The virtualizer has to know each\nrow's height as a number before it can position rows, so it cannot be\nresolved from CSS - the grid writes the height as an inline style on\nevery row, which would override a stylesheet rule anyway.\n\nSet `rowHeight` on `<SvGrid>`. Numeric, in pixels; drives the row\nheight and the active-cell hit box. The default is `30`.\n\n```svelte\n<SvGrid {data} {columns} features={features} rowHeight={28} />\n```\n\nCommon steps: `28` compact, `30` normal, `46`-`48` comfortable. For\nrows whose height depends on their content, set `autoRowHeight`\ninstead and the grid measures each row.\n\nA user-facing \"density selector\" is half a dozen lines:\n\n```svelte\n<script lang=\"ts\">\n let density = $state<'compact' | 'normal' | 'comfortable'>('normal')\n const height = $derived(\n density === 'compact' ? 28 : density === 'comfortable' ? 48 : 30,\n )\n</script>\n\n<select bind:value={density}>\n <option value=\"compact\">Compact</option>\n <option value=\"normal\">Normal</option>\n <option value=\"comfortable\">Comfortable</option>\n</select>\n\n<SvGrid {data} {columns} features={features} rowHeight={height} />\n```\n\n## Sizing the grid\n\nThe wrapper renders inside whatever container you give it. The\n`containerHeight` prop sets the scrollable shell height:\n\n```svelte\n<!-- Numeric: px -->\n<SvGrid {data} {columns} features={features} containerHeight={520} />\n\n<!-- String: passed through to CSS -->\n<SvGrid {data} {columns} features={features} containerHeight=\"100%\" />\n<SvGrid {data} {columns} features={features} containerHeight=\"auto\" />\n```\n\nFor a flex-grow layout the canonical recipe is:\n\n```svelte\n<div class=\"flex flex-col h-screen\">\n <header>…</header>\n <div class=\"flex-1 min-h-0\">\n <SvGrid {data} {columns} features={features} containerHeight=\"100%\" />\n </div>\n</div>\n```\n\nThe `min-h-0` is the bit that bites. Flex children default to\n`min-height: auto`, which prevents the inner scroll container from\nshrinking, which makes the whole page scroll instead of the grid.\n\n## Full Tailwind integration\n\nIf your app uses Tailwind, see [Tailwind integration](../help/tailwind.md)\nfor: install + PostCSS config, `@custom-variant` so the `dark:`\nmodifier follows `data-theme`, the override hooks for the stable\n`.sv-grid-*` class names, and the anti-patterns (don't `@apply` inside\ngrid selectors, don't put utility classes on grid children, don't\nfight column widths in CSS).\n\n## Try it\n\nThere is no `density` prop - row height is the density control, and it is one\nnumber. Compact for a screen someone scans all day, comfortable for one they\nread.\n\n```svelte {runnable}\n<SvGrid data={people} {columns} rowHeight={28} showRowNumbers />\n\n<SvGrid data={people} {columns} rowHeight={48} showRowNumbers />\n```\n\n## Re-theming with tokens\n\nThe grid reads `--sg-*` custom properties, so a theme is a block of variables on\nany ancestor - no theme file, no build step.\n\n```svelte {runnable}\n<div class=\"midnight\">\n <SvGrid data={people} {columns} sortable />\n</div>\n\n<style>\n .midnight {\n --sg-bg: #0f172a;\n --sg-fg: #e2e8f0;\n --sg-border: #1e293b;\n --sg-header-bg: #111c33;\n --sg-header-fg: #93c5fd;\n --sg-accent: #38bdf8;\n --sg-row-hover: #16233d;\n }\n</style>\n```\n\n<!-- tutorial:learn-7-theme -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-7-theme\" data-docs-tutorial=\"learn-7-theme\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-7-theme.mp4\" poster=\"/tutorials/learn-7-theme.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 7: themes, dark mode and density, 113 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-7-theme.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-7-theme.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 7: themes, dark mode and density</strong> (1:53, silent). <a href=\"https://www.youtube.com/watch?v=yhJMpkQ0Kww\" rel=\"noopener\">Watch with narration on YouTube</a></figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>A grid that looks like a grid library is a grid that never ships. Theming here is CSS custom properties, not utility classes and not a config object.</p>\n<p>Twenty presets ship with the package, and importing one stylesheet is the whole setup. What a preset sets is a list of custom properties, which means you can set them yourself when one is close but not right. Same five people as before.</p>\n<p>A wrapper, and a handful of properties on it. Background, text, border, the header colours, the accent, the hover. They cascade like any other custom property, so this theme is scoped to this one grid rather than the page.</p>\n<p>The whole file, so nothing is hidden: the rows, the columns, a wrapper around the grid, and the properties on that wrapper.</p>\n<p>Here is that file running. Nine declarations, and nothing about the grid was configured to accept them.</p>\n<p>Density is separate from colour, and it is the setting people actually argue about. Row height is a prop, so the compact table and the comfortable one are the same component with one number changed.</p>\n<p>Dark mode is not a second stylesheet you maintain. Every preset declares a light palette and a dark one, and an attribute on the document element picks which is live. Every surface follows, including the parts you never styled.</p>\n<p>The density control here is the same row height prop behind a dropdown, which is what it usually becomes: a preference you save per user rather than a decision you make once for everybody.</p>\n<p>The theme builder on the site edits these tokens live and hands you the CSS at the end. Every property is listed on the page below. See you in lesson eight.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-7-theme -->\n"
3968
3976
  },
3969
3977
  {
3970
3978
  "slug": "getting-started/6-going-to-production",
@@ -4342,14 +4350,14 @@ export const docs = [
4342
4350
  "path": "docs/help/columns/column-pinning.md",
4343
4351
  "title": "Column pinning",
4344
4352
  "section": "Columns",
4345
- "markdown": "# Column pinning\n\nPinning sticks a column to the **left** or **right** edge of the viewport so\nit does not scroll horizontally with the rest.\n\n![Three column regions: a pinned-left column and a pinned-right column stay fixed to the edges while the center columns scroll horizontally beneath them.](/docs-media/grid-column-pinning.svg)\n\nLive demo - pin Company left, Price right, scroll the middle:\n\n<div data-docs-demo=\"25-column-pinning\" data-height=\"480\"></div>\n\n## Through the column menu\n\nEvery column header has a menu (the `⋮` button). The menu has \"Pin left\" /\n\"Pin right\" / \"Unpin\" items.\n\n## Programmatically\n\n`SvGridApi` exposes `setColumnPinning` and `getColumnPinning` for\nread / write from outside the grid:\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n let rows = $state<Person[]>(people)\n const data = people\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 200 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 140 },\n { field: 'age', header: 'Age', width: 90 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```svelte\n<script lang=\"ts\">\n import type { SvGridApi } from '@svgrid/grid'\n let api = $state<SvGridApi<typeof features, Person> | null>(null)\n</script>\n\n<SvGrid {data} {columns} features={features}\n onApiReady={(next) => (api = next)} />\n\n<button onclick={() => api?.setColumnPinning({ left: ['company'], right: ['actions'] })}>\n Pin company left, actions right\n</button>\n<button onclick={() => api?.setColumnPinning({ left: [], right: [] })}>\n Unpin all\n</button>\n```\n\nFor initial pinning at mount, use the `initialColumnPinning` prop on\n`<SvGrid>`:\n\n```svelte\n<SvGrid {data} {columns} features={features}\n initialColumnPinning={{ left: ['company'], right: ['actions'] }} />\n```\n\n`getColumnPinning()` returns `{ left: string[]; right: string[] }` -\nthe array order is the visible order along the pinned edge.\n\n## Rendering\n\n- Pinned-left columns sit at the start of the visible row, with `position:\n sticky; left: <offset>px; z-index: 3`.\n- Pinned-right columns sit at the end of the visible row, with `position:\n sticky; right: <offset>px; z-index: 3`.\n- Offsets cascade - second-left column sits at the cumulative width of\n the first.\n\n## Styling\n\nPinned columns are visually differentiated from the scrollable middle\nthrough three layered cues:\n\n1. **A distinct background tint** so the pinned strip reads as \"frozen\"\n even before you scroll.\n2. **A 1-pixel divider** on the inside edge (the side facing the\n scrollable region) and a soft drop shadow that fades into the\n scroll area.\n3. **A bolder header** font weight so the frozen header reads as part\n of the grid chrome.\n\nAll three cues are driven by CSS custom properties. Override them to\nmatch your design system:\n\n| Token | Default | Used for |\n|-----------------------------|---------------------------------------------------------------------------|--------------------------------|\n| `--sg-pinned-bg` | `color-mix(in oklab, var(--sg-header-bg) 92%, var(--sg-accent) 8%)` | Body cells in pinned columns |\n| `--sg-pinned-header-bg` | `color-mix(in oklab, var(--sg-header-bg) 86%, var(--sg-accent) 14%)` | Header cells in pinned columns |\n| `--sg-pinned-divider` | `var(--sg-border)` | The 1-pixel inside-edge line |\n| `--sg-pinned-shadow-color` | `rgba(15, 23, 42, 0.22)` | The drop shadow into the scroll area |\n\n> **Keep the pinned background opaque.** When you override `--sg-pinned-bg`\n> with `color-mix`, the two percentages **must sum to 100%**. If they add up\n> to less (e.g. `60% + 20% = 80%`), CSS scales the result's alpha down to\n> `0.8` - the pinned column turns semi-transparent and the scrolling middle\n> columns bleed through it. Always pair the percentages as `N%` / `(100 - N)%`.\n\nThe fallbacks compute a subtle accent-tinted background from your\nexisting header background, so a pinned column never looks identical\nto the rest of the grid even if you don't set anything. That works\nwell for a low-chroma accent. If yours is saturated, the derived tint\ncan get loud enough to compete with your selection colour - name a\nneutral fill instead and let the divider and shadow carry the\nboundary:\n\n```css\n.themed-host {\n --sg-pinned-bg: var(--sg-row-alt-bg);\n --sg-pinned-header-bg: var(--sg-header-bg);\n --sg-pinned-divider: var(--sg-border);\n --sg-pinned-shadow-color: rgba(94, 72, 52, 0.18);\n}\n```\n\nTo opt out of the tint and match the body exactly:\n\n```css\n.themed-host {\n --sg-pinned-bg: var(--sg-bg);\n --sg-pinned-header-bg: var(--sg-header-bg);\n}\n```\n\nOr to make pinned columns *very* obvious - useful for high-density\nfinancial grids where the user must instantly know which side is\nfrozen:\n\n```css\n.themed-host {\n --sg-pinned-bg: color-mix(in oklab, var(--sg-bg) 80%, var(--sg-accent) 20%);\n --sg-pinned-header-bg: color-mix(in oklab, var(--sg-bg) 70%, var(--sg-accent) 30%);\n --sg-pinned-divider: var(--sg-accent);\n}\n```\n\n### Hover, selection, and zebra rows\n\nThe pinned tint sits *under* the hover, selection, and zebra row\nbackgrounds via stacked `linear-gradient` paints. That means:\n\n- Hovering a row in a pinned cell shows the pinned tint *with* the\n hover overlay on top - the user still sees the row highlight.\n- Selecting a row layers the selection colour over the pinned tint.\n- Zebra rows re-paint the pinned cell so the alternating row\n background doesn't leak through the pin.\n\nYou don't need to override anything for these states to work; they\nfollow `--sg-row-hover-bg`, `--sg-selection-bg`, and the pinned tokens\nautomatically.\n\n## Multiple pinned columns\n\nMultiple pins are stacked in the order they were pinned. The first-pinned\ncolumn is the outermost.\n\n## Gotchas\n\n- Pinning **plus** column virtualization is supported, but the pinned\n columns are always rendered (they never enter the virtualized window).\n- If you have so many pinned columns that they exceed the viewport width\n there is no horizontal scrollbar within the pinned regions - the user\n loses access to the non-pinned middle. Pin only \"anchor\" columns\n (identifier, action, status) and keep the count single-digit.\n\n## Try it\n\nPinning is a grid-level prop naming column ids, not a flag on the column.\nScroll the grid sideways: Name stays at the left edge, Salary at the right.\n\n```svelte {runnable}\n<SvGrid\n data={people}\n {columns}\n initialColumnPinning={{ left: ['name'], right: ['salary'] }}\n/>\n```\n\n## Pinning both edges\n\nPinned columns stay put while the middle scrolls. Narrow the browser and the\ntwo pinned columns hold their edges while Department and City pan between them.\n\n```svelte {runnable}\n<SvGrid\n data={people}\n {columns}\n initialColumnPinning={{ left: ['name'], right: ['salary'] }}\n sortable\n/>\n```\n\n## See also\n\n- [Column state](./column-state.md)\n- [`SvGridApi` reference](../../reference/SvGridApi.md) - `setColumnPinning` / `getColumnPinning` signatures.\n"
4353
+ "markdown": "# Column pinning\n\nPinning sticks a column to the **left** or **right** edge of the viewport so\nit does not scroll horizontally with the rest.\n\n![Three column regions: a pinned-left column and a pinned-right column stay fixed to the edges while the center columns scroll horizontally beneath them.](/docs-media/grid-column-pinning.svg)\n\nLive demo - pin Company left, Price right, scroll the middle:\n\n<div data-docs-demo=\"25-column-pinning\" data-height=\"480\"></div>\n\n## Through the column menu\n\nEvery column header has a menu (the `⋮` button). The menu has \"Pin left\" /\n\"Pin right\" / \"Unpin\" items.\n\n## Programmatically\n\n`SvGridApi` exposes `setColumnPinning` and `getColumnPinning` for\nread / write from outside the grid:\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n let rows = $state<Person[]>(people)\n const data = people\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 200 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 140 },\n { field: 'age', header: 'Age', width: 90 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```svelte\n<script lang=\"ts\">\n import type { SvGridApi } from '@svgrid/grid'\n let api = $state<SvGridApi<typeof features, Person> | null>(null)\n</script>\n\n<SvGrid {data} {columns} features={features}\n onApiReady={(next) => (api = next)} />\n\n<button onclick={() => api?.setColumnPinning({ left: ['company'], right: ['actions'] })}>\n Pin company left, actions right\n</button>\n<button onclick={() => api?.setColumnPinning({ left: [], right: [] })}>\n Unpin all\n</button>\n```\n\nFor initial pinning at mount, use the `initialColumnPinning` prop on\n`<SvGrid>`:\n\n```svelte\n<SvGrid {data} {columns} features={features}\n initialColumnPinning={{ left: ['company'], right: ['actions'] }} />\n```\n\n`getColumnPinning()` returns `{ left: string[]; right: string[] }` -\nthe array order is the visible order along the pinned edge.\n\n## Rendering\n\n- Pinned-left columns sit at the start of the visible row, with `position:\n sticky; left: <offset>px; z-index: 3`.\n- Pinned-right columns sit at the end of the visible row, with `position:\n sticky; right: <offset>px; z-index: 3`.\n- Offsets cascade - second-left column sits at the cumulative width of\n the first.\n\n## Styling\n\nPinned columns are visually differentiated from the scrollable middle\nthrough three layered cues:\n\n1. **A distinct background tint** so the pinned strip reads as \"frozen\"\n even before you scroll.\n2. **A 1-pixel divider** on the inside edge (the side facing the\n scrollable region) and a soft drop shadow that fades into the\n scroll area.\n3. **A bolder header** font weight so the frozen header reads as part\n of the grid chrome.\n\nAll three cues are driven by CSS custom properties. Override them to\nmatch your design system:\n\n| Token | Default | Used for |\n|-----------------------------|---------------------------------------------------------------------------|--------------------------------|\n| `--sg-pinned-bg` | `color-mix(in oklab, var(--sg-header-bg) 92%, var(--sg-accent) 8%)` | Body cells in pinned columns |\n| `--sg-pinned-header-bg` | `color-mix(in oklab, var(--sg-header-bg) 86%, var(--sg-accent) 14%)` | Header cells in pinned columns |\n| `--sg-pinned-divider` | `var(--sg-border)` | The 1-pixel inside-edge line |\n| `--sg-pinned-shadow-color` | `rgba(15, 23, 42, 0.22)` | The drop shadow into the scroll area |\n\n> **Keep the pinned background opaque.** When you override `--sg-pinned-bg`\n> with `color-mix`, the two percentages **must sum to 100%**. If they add up\n> to less (e.g. `60% + 20% = 80%`), CSS scales the result's alpha down to\n> `0.8` - the pinned column turns semi-transparent and the scrolling middle\n> columns bleed through it. Always pair the percentages as `N%` / `(100 - N)%`.\n\nThe fallbacks compute a subtle accent-tinted background from your\nexisting header background, so a pinned column never looks identical\nto the rest of the grid even if you don't set anything. That works\nwell for a low-chroma accent. If yours is saturated, the derived tint\ncan get loud enough to compete with your selection colour - name a\nneutral fill instead and let the divider and shadow carry the\nboundary:\n\n```css\n.themed-host {\n --sg-pinned-bg: var(--sg-row-alt-bg);\n --sg-pinned-header-bg: var(--sg-header-bg);\n --sg-pinned-divider: var(--sg-border);\n --sg-pinned-shadow-color: rgba(94, 72, 52, 0.18);\n}\n```\n\nTo opt out of the tint and match the body exactly:\n\n```css\n.themed-host {\n --sg-pinned-bg: var(--sg-bg);\n --sg-pinned-header-bg: var(--sg-header-bg);\n}\n```\n\nOr to make pinned columns *very* obvious - useful for high-density\nfinancial grids where the user must instantly know which side is\nfrozen:\n\n```css\n.themed-host {\n --sg-pinned-bg: color-mix(in oklab, var(--sg-bg) 80%, var(--sg-accent) 20%);\n --sg-pinned-header-bg: color-mix(in oklab, var(--sg-bg) 70%, var(--sg-accent) 30%);\n --sg-pinned-divider: var(--sg-accent);\n}\n```\n\n### Hover, selection, and zebra rows\n\nThe pinned tint sits *under* the hover, selection, and zebra row\nbackgrounds via stacked `linear-gradient` paints. That means:\n\n- Hovering a row in a pinned cell shows the pinned tint *with* the\n hover overlay on top - the user still sees the row highlight.\n- Selecting a row layers the selection colour over the pinned tint.\n- Zebra rows re-paint the pinned cell so the alternating row\n background doesn't leak through the pin.\n\nYou don't need to override anything for these states to work; they\nfollow `--sg-row-hover-bg`, `--sg-selection-bg`, and the pinned tokens\nautomatically.\n\n## Multiple pinned columns\n\nMultiple pins are stacked in the order they were pinned. The first-pinned\ncolumn is the outermost.\n\n## Gotchas\n\n- Pinning **plus** column virtualization is supported. The pinned columns\n are always rendered, as their own runs either side of the virtual window;\n the columns between them stay virtualized, so a wide grid with pinned\n edges renders only the columns in view plus the pinned ones.\n- If you have so many pinned columns that they exceed the viewport width\n there is no horizontal scrollbar within the pinned regions - the user\n loses access to the non-pinned middle. Pin only \"anchor\" columns\n (identifier, action, status) and keep the count single-digit.\n\n## Try it\n\nPinning is a grid-level prop naming column ids, not a flag on the column.\nScroll the grid sideways: Name stays at the left edge, Salary at the right.\n\n```svelte {runnable}\n<SvGrid\n data={people}\n {columns}\n initialColumnPinning={{ left: ['name'], right: ['salary'] }}\n/>\n```\n\n## Pinning both edges\n\nPinned columns stay put while the middle scrolls. Narrow the browser and the\ntwo pinned columns hold their edges while Department and City pan between them.\n\n```svelte {runnable}\n<SvGrid\n data={people}\n {columns}\n initialColumnPinning={{ left: ['name'], right: ['salary'] }}\n sortable\n/>\n```\n\n## See also\n\n- [Column state](./column-state.md)\n- [`SvGridApi` reference](../../reference/SvGridApi.md) - `setColumnPinning` / `getColumnPinning` signatures.\n"
4346
4354
  },
4347
4355
  {
4348
4356
  "slug": "help/columns/column-sizing",
4349
4357
  "path": "docs/help/columns/column-sizing.md",
4350
4358
  "title": "Column sizing",
4351
4359
  "section": "Columns",
4352
- "markdown": "# Column sizing\n\nEach column has a pixel width. The default for all columns is the grid's\n`columnWidth` prop (default ~140 px); each column can override via its\n`width` field.\n<div data-docs-demo=\"63-column-layout-api\" data-height=\"540\"></div>\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n let rows = $state<Person[]>(people)\n const data = people\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 200 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 140 },\n { field: 'age', header: 'Age', width: 90 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```ts\nconst columns: GridColumns<Person> = [\n { field: 'firstName', header: 'First name', width: 150 },\n { field: 'department', header: 'Department', width: 180 },\n { field: 'salary', header: 'Salary', width: 120 },\n]\n```\n\n```svelte {runnable}\n<SvGrid {columns} {data} features={{}} columnWidth={140} />\n```\n\n## User resizing\n\nOff by default. Set `columnResize` and every header grows a drag handle on its\nright edge: drag to widen or narrow, minimum 40 px, or focus the handle and use\nLeft/Right (Shift for 1px steps). Widths are stored per column id inside the\ngrid, so a resize survives sorting and filtering.\n\nLeaving it off is the reason `width` means what it says - the width you asked\nfor, not a starting point a user can drag away:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = { id: number; name: string; city: string }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', city: 'London' },\n { id: 2, name: 'Grace Hopper', city: 'New York' },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 180 },\n { field: 'city', header: 'City', width: 150 },\n ]\n</script>\n\n<p>Fixed widths (the default):</p>\n<SvGrid data={people} {columns} containerHeight={140} />\n\n<p>With <code>columnResize</code> - drag a header edge:</p>\n<SvGrid data={people} {columns} containerHeight={140} columnResize />\n```\n\n### A drag is one undo\n\nA width the user drags, or double-clicks into, is one step in the grid's\nundo history: Ctrl+Z puts it back, Ctrl+Y widens it again. `onColumnResize`\nreports each such change with the column id and the new width.\n`api.setColumnWidth` and `api.autosizeColumn` record nothing and fire\nnothing, so restoring saved widths does not fill the history.\n\n### Locking one column: `resizable: false`\n\n`columnResize` turns resizing on for the grid; a column opts back out with\n`resizable: false`. Use it where the width is part of the layout rather than a\npreference - a row-number gutter, a checkbox column, a fixed icon column.\n\nOpting out removes that column's handle entirely, so the drag, the arrow keys\nand double-click-to-autosize all go with it, and the column menu drops its\nAutosize item. Nothing is left that lets a user change the width by accident:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = { id: number; name: string; city: string }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', city: 'London' },\n { id: 2, name: 'Grace Hopper', city: 'New York' },\n ]\n\n const columns: GridColumns<Person> = [\n // Fixed: the row id is 60px because that is what fits, not a preference.\n { field: 'id', header: '#', width: 60, resizable: false },\n { field: 'name', header: 'Name', width: 200 },\n { field: 'city', header: 'City', width: 160 },\n ]\n</script>\n\n<SvGrid data={people} {columns} columnResize containerHeight={150} />\n```\n\nThe column option only ever subtracts. `resizable: true` on a grid without\n`columnResize` does nothing - the grid-wide prop is still the switch.\n\nProgrammatic sizing is unaffected either way: `api.setColumnWidth()`,\n`api.autosizeColumn()` and `fitColumns` all still apply to a locked column,\nexactly as they do when `columnResize` is off.\n\nThe handles come from an action the grid loads on demand, so a grid that leaves\n`columnResize` off never downloads that code.\n\n## Programmatic resizing + persistence\n\nThe imperative API exposes `setColumnWidth` and `getColumnWidths`:\n\n```svelte\n<script lang=\"ts\">\n import type { SvGridApi } from '@svgrid/grid'\n let api = $state<SvGridApi<typeof features, Person> | null>(null)\n\n function save() {\n if (!api) return\n localStorage.setItem('widths', JSON.stringify(api.getColumnWidths()))\n }\n function restore() {\n if (!api) return\n const saved: Record<string, number> = JSON.parse(localStorage.getItem('widths') ?? '{}')\n for (const [id, w] of Object.entries(saved)) api.setColumnWidth(id, w)\n }\n</script>\n\n<SvGrid {data} {columns} features={features}\n onApiReady={(next) => (api = next)} />\n\n<button onclick={save}>Save layout</button>\n<button onclick={restore}>Restore layout</button>\n```\n\n`getColumnWidths()` returns every column's *current effective* width\n(user resize OR columnDef `width` OR grid-wide default), so the\nsnapshot round-trips cleanly.\n\n## Auto-fit\n\n`fitColumns={true}` scales every column proportionally to fill the\nviewport - the wrapper handles rounding-residue absorption + a modest\nshrink-to-fit (down to ~85 % of natural widths). For \"fit to content\"\n(longest cell text), pre-compute per-column widths once after a data\nload and call `api.setColumnWidth(id, w)` per column.\n\n## Column virtualization\n\nWith many columns, enable column virtualization so only the visible columns\nrender:\n\n```svelte {runnable}\n<SvGrid\n {columns}\n {data}\n features={{}}\n columnVirtualization={true}\n columnWidth={120}\n columnOverscan={3}\n/>\n```\n\nSee [examples/src/demos/06-large-dataset.svelte](../../../examples/src/demos/06-large-dataset.svelte)\nfor a 100-column virtualized grid.\n\n## Collapsing a column to nothing\n\n`api.setColumnCollapsed(id, true)` folds a column away the way a spreadsheet\nhides one: the column keeps its place, its cells and the width it had, and\ntakes no room until `setColumnCollapsed(id, false)` unfolds it. Its\nneighbours' borders meet at the seam. Arrow keys, Tab and Enter step over a\ncollapsed column, so the cursor never rests on it; `api.setActiveCell` and a\nsheet's Name Box still can. `api.isColumnCollapsed(id)` reads the state.\n\nThis is not `setColumnVisible(id, false)`, which takes the column out of the\nmodel: every column after a hidden one moves up an index, which is right for\na \"Choose columns\" panel and wrong for anything that addresses cells by\nposition, such as a sheet's formulas and formats. Collapse when the indices\nmust hold; hide when the column should be gone.\n\n```ts\napi.setColumnCollapsed('cost', true) // folded: cells and width kept, no room\napi.isColumnCollapsed('cost') // true\napi.setColumnCollapsed('cost', false) // back at the width it had\n```\n\nThe spreadsheet shell's Hide and Unhide (Ctrl+9, Ctrl+0, and the header\nmenus) are this, kept per sheet.\n\n## Gotchas\n\n- Under `columnResize`, a `width` in the column def is an **initial** width:\n once the user drags, their override wins for the rest of the session. Without\n the prop it is simply the width.\n- To persist what the user chose, read `api.getColumnWidths()` and replay it\n with `api.setColumnWidth(id, w)` - see [Programmatic resizing](#programmatic-resizing--persistence)\n above. `api.getState()` carries `columnWidths` too, if you are already\n saving a whole view.\n\n## Try it\n\nWithout `fitColumns` the explicit widths are used as-is and any leftover space\nstays empty on the right. With it, the columns scale to fill. Both carry\n`columnResize`, so drag a header edge in either - a user resize wins from then\non, including over `fitColumns`.\n\n```svelte {runnable}\n<SvGrid data={people} {columns} columnResize />\n\n<SvGrid data={people} {columns} columnResize fitColumns />\n```\n\n## See also\n\n- [Column moving](./column-moving.md)\n- [Column pinning](./column-pinning.md)\n"
4360
+ "markdown": "# Column sizing\n\nEach column has a pixel width. The default for all columns is the grid's\n`columnWidth` prop (default ~140 px); each column can override via its\n`width` field.\n<div data-docs-demo=\"63-column-layout-api\" data-height=\"540\"></div>\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n let rows = $state<Person[]>(people)\n const data = people\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 200 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 140 },\n { field: 'age', header: 'Age', width: 90 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```ts\nconst columns: GridColumns<Person> = [\n { field: 'firstName', header: 'First name', width: 150 },\n { field: 'department', header: 'Department', width: 180 },\n { field: 'salary', header: 'Salary', width: 120 },\n]\n```\n\n```svelte {runnable}\n<SvGrid {columns} {data} features={{}} columnWidth={140} />\n```\n\n## User resizing\n\nOff by default. Set `columnResize` and every header grows a drag handle on its\nright edge: drag to widen or narrow, minimum 40 px, or focus the handle and use\nLeft/Right (Shift for 1px steps). Widths are stored per column id inside the\ngrid, so a resize survives sorting and filtering.\n\nLeaving it off is the reason `width` means what it says - the width you asked\nfor, not a starting point a user can drag away:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = { id: number; name: string; city: string }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', city: 'London' },\n { id: 2, name: 'Grace Hopper', city: 'New York' },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 180 },\n { field: 'city', header: 'City', width: 150 },\n ]\n</script>\n\n<p>Fixed widths (the default):</p>\n<SvGrid data={people} {columns} containerHeight={140} />\n\n<p>With <code>columnResize</code> - drag a header edge:</p>\n<SvGrid data={people} {columns} containerHeight={140} columnResize />\n```\n\n### A drag is one undo\n\nA width the user drags, or double-clicks into, is one step in the grid's\nundo history: Ctrl+Z puts it back, Ctrl+Y widens it again. `onColumnResize`\nreports each such change with the column id and the new width.\n`api.setColumnWidth` and `api.autosizeColumn` record nothing and fire\nnothing, so restoring saved widths does not fill the history.\n\n### Locking one column: `resizable: false`\n\n`columnResize` turns resizing on for the grid; a column opts back out with\n`resizable: false`. Use it where the width is part of the layout rather than a\npreference - a row-number gutter, a checkbox column, a fixed icon column.\n\nOpting out removes that column's handle entirely, so the drag, the arrow keys\nand double-click-to-autosize all go with it, and the column menu drops its\nAutosize item. Nothing is left that lets a user change the width by accident:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = { id: number; name: string; city: string }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', city: 'London' },\n { id: 2, name: 'Grace Hopper', city: 'New York' },\n ]\n\n const columns: GridColumns<Person> = [\n // Fixed: the row id is 60px because that is what fits, not a preference.\n { field: 'id', header: '#', width: 60, resizable: false },\n { field: 'name', header: 'Name', width: 200 },\n { field: 'city', header: 'City', width: 160 },\n ]\n</script>\n\n<SvGrid data={people} {columns} columnResize containerHeight={150} />\n```\n\nThe column option only ever subtracts. `resizable: true` on a grid without\n`columnResize` does nothing - the grid-wide prop is still the switch.\n\nProgrammatic sizing is unaffected either way: `api.setColumnWidth()`,\n`api.autosizeColumn()` and `fitColumns` all still apply to a locked column,\nexactly as they do when `columnResize` is off.\n\nThe handles come from an action the grid loads on demand, so a grid that leaves\n`columnResize` off never downloads that code.\n\n## Programmatic resizing + persistence\n\nThe imperative API exposes `setColumnWidth` and `getColumnWidths`:\n\n```svelte\n<script lang=\"ts\">\n import type { SvGridApi } from '@svgrid/grid'\n let api = $state<SvGridApi<typeof features, Person> | null>(null)\n\n function save() {\n if (!api) return\n localStorage.setItem('widths', JSON.stringify(api.getColumnWidths()))\n }\n function restore() {\n if (!api) return\n const saved: Record<string, number> = JSON.parse(localStorage.getItem('widths') ?? '{}')\n for (const [id, w] of Object.entries(saved)) api.setColumnWidth(id, w)\n }\n</script>\n\n<SvGrid {data} {columns} features={features}\n onApiReady={(next) => (api = next)} />\n\n<button onclick={save}>Save layout</button>\n<button onclick={restore}>Restore layout</button>\n```\n\n`getColumnWidths()` returns every column's *current effective* width\n(user resize OR columnDef `width` OR grid-wide default), so the\nsnapshot round-trips cleanly.\n\n## Auto-fit\n\n`fitColumns={true}` scales every column proportionally to fill the\nviewport - the wrapper handles rounding-residue absorption + a modest\nshrink-to-fit (down to ~85 % of natural widths). For \"fit to content\"\n(longest cell text), pre-compute per-column widths once after a data\nload and call `api.setColumnWidth(id, w)` per column.\n\n## Column virtualization\n\nWith many columns, enable column virtualization so only the visible columns\nrender:\n\n```svelte {runnable}\n<SvGrid\n {columns}\n {data}\n features={{}}\n columnVirtualization={true}\n columnWidth={120}\n columnOverscan={3}\n/>\n```\n\n`columnOverscan` is how many columns stay rendered ahead of a horizontal\nscroll at all times (default 3), with one kept behind it. The rendered\ncolumns don't move on every column boundary: they stay put while that\nmany are left ahead, then move three at once. Each move updates every\nrendered row, whether one column comes in or four, so fewer, larger\nmoves are less work, and the scroll frames in between change nothing.\nRight after a move, up to `columnOverscan + 2`\ncolumns are rendered ahead. Raise it if fast scrolling shows blank columns\nbefore they fill in; lower it for less work per scroll.\n\nSee [examples/src/demos/06-large-dataset.svelte](../../../examples/src/demos/06-large-dataset.svelte)\nfor a 100-column virtualized grid.\n\n## Collapsing a column to nothing\n\n`api.setColumnCollapsed(id, true)` folds a column away the way a spreadsheet\nhides one: the column keeps its place, its cells and the width it had, and\ntakes no room until `setColumnCollapsed(id, false)` unfolds it. Its\nneighbours' borders meet at the seam. Arrow keys, Tab and Enter step over a\ncollapsed column, so the cursor never rests on it; `api.setActiveCell` and a\nsheet's Name Box still can. `api.isColumnCollapsed(id)` reads the state.\n\nThis is not `setColumnVisible(id, false)`, which takes the column out of the\nmodel: every column after a hidden one moves up an index, which is right for\na \"Choose columns\" panel and wrong for anything that addresses cells by\nposition, such as a sheet's formulas and formats. Collapse when the indices\nmust hold; hide when the column should be gone.\n\n```ts\napi.setColumnCollapsed('cost', true) // folded: cells and width kept, no room\napi.isColumnCollapsed('cost') // true\napi.setColumnCollapsed('cost', false) // back at the width it had\n```\n\nThe spreadsheet shell's Hide and Unhide (Ctrl+9, Ctrl+0, and the header\nmenus) are this, kept per sheet.\n\n## Gotchas\n\n- Under `columnResize`, a `width` in the column def is an **initial** width:\n once the user drags, their override wins for the rest of the session. Without\n the prop it is simply the width.\n- To persist what the user chose, read `api.getColumnWidths()` and replay it\n with `api.setColumnWidth(id, w)` - see [Programmatic resizing](#programmatic-resizing--persistence)\n above. `api.getState()` carries `columnWidths` too, if you are already\n saving a whole view.\n\n## Try it\n\nWithout `fitColumns` the explicit widths are used as-is and any leftover space\nstays empty on the right. With it, the columns scale to fill. Both carry\n`columnResize`, so drag a header edge in either - a user resize wins from then\non, including over `fitColumns`.\n\n```svelte {runnable}\n<SvGrid data={people} {columns} columnResize />\n\n<SvGrid data={people} {columns} columnResize fitColumns />\n```\n\n## See also\n\n- [Column moving](./column-moving.md)\n- [Column pinning](./column-pinning.md)\n"
4353
4361
  },
4354
4362
  {
4355
4363
  "slug": "help/columns/column-spanning",
@@ -4505,6 +4513,13 @@ export const docs = [
4505
4513
  "section": "Help",
4506
4514
  "markdown": "# Expression query language - Enterprise\n\nThe expression language is a small, dependency-free query language your users\nauthor through the UI. It is the connective tissue behind [alerts](./alerts.md)\n(and, in time, styled and calculated columns): one model for predicates, scalar\nmaths, and change detection, evaluated the same way everywhere.\n\nIt ships in `@svgrid/enterprise`. Leaf comparisons delegate to the grid's own\n`applyExcelFilter`, so an expression's operators mean exactly what the column\nfilter menu means.\n\n## The editor\n\n`<SvExpressionEditor>` offers two modes over the same value:\n\n- **Builder** (default) - a list of conditions (column + operator + value)\n combined with *all* (AND) or *any* (OR), just like the filter row. Set\n operators show a token input; `between` shows two fields; blank operators show\n none.\n- **Text** - a free-text box for power users, with live validation and column\n maths.\n\nBoth show a live \"matches N of M\" count against a sample `rows` array.\n\n```svelte\n<script lang=\"ts\">\n import { SvExpressionEditor, type ExprColumn, type PredicateExpr } from '@svgrid/enterprise'\n\n const columns: ExprColumn[] = [\n { id: 'price', name: 'Price', type: 'number' },\n { id: 'region', name: 'Region', type: 'text' },\n ]\n let expr = $state<PredicateExpr>({ kind: 'const', value: true })\n</script>\n\n<SvExpressionEditor {columns} bind:value={expr} rows={sample} />\n```\n\n## Text syntax\n\nColumn references are a bare id (`price`) or a bracketed label (`[Unit Price]`)\nwhen the name has spaces.\n\n```\nprice > 100 AND region IN (\"EU\", \"US\")\nname CONTAINS \"widget\" OR name STARTSWITH \"gadget\"\nprice BETWEEN 10 AND 20\nname ISBLANK\nprice / qty >= 40 -- cross-column maths\nSUM(amount) > 10000 -- aggregate over the rows in scope\n```\n\n- Logic: `AND`, `OR`, `NOT`, parentheses.\n- Text/set/range operators: `CONTAINS`, `STARTSWITH`, `ENDSWITH`, `MATCHES`\n (regex), `IN (…)`, `BETWEEN … AND …`, `ISBLANK`, `ISNOTBLANK`.\n- Symbolic comparators: `=`, `!=`, `>`, `<`, `>=`, `<=` (these also drive\n cross-column maths).\n- Arithmetic: `+`, `-`, `*`, `/`, `%`, unary `-`.\n- Aggregates: `SUM(col)`, `AVG(col)`, `COUNT(col)`.\n- Functions: `ABS`, `ROUND`, `FLOOR`, `CEIL`, `MIN`, `MAX`, `IF`, `COALESCE`,\n `CONCAT`, `LOWER`, `UPPER`, `LEN`.\n\nA `column = literal` comparison parses to a grid-filter `cmp` node (so it folds\naccents/case like the filter row); general comparisons and maths parse to a\n`scalarCmp`.\n\n## The model\n\nThe canonical form is a JSON AST - no parser is needed at runtime. Three\nfamilies:\n\n- **`PredicateExpr`** - boolean: `and` / `or` / `not` / `cmp` (a filter-operator\n test) / `scalarCmp` (compare two scalar expressions) / `const`.\n- **`ScalarExpr`** - a single value: `col` / `lit` / `bin` (arithmetic) / `agg` /\n `func`.\n- **`ChangeExpr`** - `changed` / `delta` / `percentChange` / `crossed`, for the\n \"relative change\" alert trigger.\n\n## Evaluating expressions\n\n```ts\nimport { evaluatePredicate, parsePredicate, validateExpression } from '@svgrid/enterprise'\n\nconst expr = parsePredicate('price > 100 AND region = \"EU\"', columns)\nvalidateExpression(expr, columns) // [] when sound\nevaluatePredicate(expr, { row: { price: 120, region: 'EU' } }) // true\n```\n\n`evaluateScalar` and `evaluateChange` cover the other two families. All three are\npure functions - safe to unit-test and to run in a Web Worker.\n\nAdvanced scalar functions can be injected per evaluation via the context's\n`functions` map (for example, to delegate a formula to HyperFormula) without\nmaking it a hard dependency.\n\n## Try it\n\nBuild a predicate in the visual editor and watch the AST underneath. The same\nvalue round-trips through the text mode, which is what makes it storable.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvExpressionEditor } from '@svgrid/enterprise'\n import type { ExprColumn, PredicateExpr } from '@svgrid/enterprise'\n\n const columns: ExprColumn[] = [\n { id: 'name', name: 'Name', type: 'text' },\n { id: 'department', name: 'Department', type: 'text' },\n { id: 'age', name: 'Age', type: 'number' },\n { id: 'salary', name: 'Salary', type: 'number' },\n ]\n\n const rows = [\n { name: 'Ada Lovelace', department: 'Engineering', age: 36, salary: 142000 },\n { name: 'Grace Hopper', department: 'Engineering', age: 45, salary: 168000 },\n { name: 'Linus Torvalds', department: 'Platform', age: 54, salary: 155000 },\n ]\n\n let expr = $state<PredicateExpr>({ kind: 'const', value: true })\n</script>\n\n<SvExpressionEditor {columns} {rows} bind:value={expr} />\n\n<pre>{JSON.stringify(expr, null, 2)}</pre>\n```\n\nThe AST is plain JSON, so a rule authored here is something you can put in a\ndatabase and evaluate later - which is exactly what [alerts](./alerts.md) do\nwith it.\n\n## See also\n\n- [Alerts](./alerts.md) - the flagship consumer of this language.\n- [Filtering](./filtering/overview.md) - the column filter operators the `cmp` nodes reuse.\n"
4507
4515
  },
4516
+ {
4517
+ "slug": "help/figma",
4518
+ "path": "docs/help/figma.md",
4519
+ "title": "Figma design kit",
4520
+ "section": "Help",
4521
+ "markdown": "# Figma design kit\n\nSvGrid's design kit is on Figma Community:\n[SvGrid Design Kit](https://www.figma.com/community/file/1689644521333526144).\nDuplicate it into your drafts to design screens with the grid and the UI\ncomponents, then hand them to developers with the same names the code uses.\n\nThe kit is generated from this repository (`tools/figma`), so its colors,\nsizes and states are the ones the components render. Theme colors come from\nthe same `resolveThemeTokens()` function that writes\n`@svgrid/grid/themes/*.css`.\n\n## What is in the file\n\n- **Overview.** The palettes of each theme, every color and number variable\n per mode, the text styles and the shadows.\n- **Grid.** A complete orders screen, the example grid in every theme and\n density, then each part of the grid with its states: header cells and\n column groups, body cells (hover, selected, active, editing, invalid,\n range), group, tree, pinned and total rows, the pager, toolbar, status bar,\n find bar, tool panel, the column, filter, context and operator menus, and\n the loading and empty states.\n- **Components.** 37 UI components from `@svgrid/grid` in seven groups:\n actions, inputs, selection, navigation, data display, feedback and\n overlays.\n\nEvery component sits on a card with its Svelte usage, the props that change\nits look, the source file it was measured from, and its variants in light and\ndark.\n\n## Variables are the CSS tokens\n\nEach color in the kit is a variable named after its `--sg-*` custom\nproperty, and Dev Mode shows the property as the variable's code. A selected\nrow in a design reads `var(--sg-selection-bg)`, which is what the developer\nsets or leaves to the theme.\n\n| Figma variable | In code |\n|---|---|\n| `surface/bg` | `--sg-bg` |\n| `text/fg`, `text/muted` | `--sg-fg`, `--sg-muted` |\n| `border/border` | `--sg-border` |\n| `accent/accent` | `--sg-accent` |\n| `header/bg` | `--sg-header-bg` |\n| `row/selection-bg` | `--sg-selection-bg` |\n| `pinned/bg` | `--sg-pinned-bg` |\n| `status/danger` | `--sg-danger` |\n| `alpha/success-14` | `color-mix(in srgb, var(--sg-success) 14%, transparent)` |\n\nTranslucent fills, such as the soft badge and chip backgrounds, are\n`alpha/*` variables whose code is the `color-mix()` the CSS writes.\n\n## Themes and density\n\nThe `SvGrid` collection has one mode per theme: Ember (the default) and\nshadcn/ui, each light and dark. Set the mode on a frame to re-theme\neverything inside it. In code, that is the theme stylesheet plus the\n`data-theme` attribute on `<html>` for dark:\n\n```css\n@import '@svgrid/grid/themes/ember.css';\n```\n\nThe `SvGrid density` collection holds row height, header height and cell\npadding:\n\n| Variable | Default | Comfortable | In code |\n|---|---|---|---|\n| `grid/row-height` | 30 | 36 | `rowHeight` prop |\n| `grid/header-height` | 22 (content-sized) | 48 | `--sg-header-min-height` |\n| `grid/cell-px` | 7 | 12 | `--sg-cell-px` |\n\nA design in Comfortable density translates to:\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n import '@svgrid/grid/themes/ember.css'\n\n type Order = { id: string; customer: string; total: number }\n const rows: Order[] = [{ id: '#10482', customer: 'Tailspin Toys', total: 1284 }]\n const columns: GridColumns<Order> = [\n { field: 'id', header: 'Order', width: 110 },\n { field: 'customer', header: 'Customer', width: 210 },\n { field: 'total', header: 'Total', width: 120, align: 'right' },\n ]\n</script>\n\n<div style=\"--sg-cell-px: 12px; --sg-header-min-height: 48px\">\n <SvGrid data={rows} {columns} rowHeight={36} showRowSelection pageable />\n</div>\n```\n\nThe other 18 theme presets are not in the Community file. See\n[Design tokens](./tokens.md) for every token and\n[Theme and density](../getting-started/5-theme-and-density.md) for the\npresets.\n\n## Components map to props\n\nVariant properties in the kit follow the Svelte props. A `Button` instance\nset to `Variant=Secondary, Size=sm` is `<SvButton variant=\"secondary\"\nsize=\"sm\">`. `State` (Hover, Focus, Disabled, ...) shows how the component\nlooks in that state; in code most states come from interaction, and\n`disabled`, `invalid` and `loading` are props.\n\n## Where the kit differs from a browser\n\n- Inter stands in for the system font stack (`ui-sans-serif, system-ui`).\n- The grid takes its font size from the page it sits on. The kit uses 13px,\n the size of the grid's own pager, menus and filter inputs.\n- Heights assume a border-box reset, as most app shells and svgrid.com have.\n- Alert icons and pager arrows are text characters in the CSS and render in\n Inter in the kit.\n\n## Rebuilding the kit\n\nThe plugin that builds the file lives in `tools/figma`. To build it with\nother theme presets, for example for your own team library:\n\n```bash\npnpm figma:build\nnode tools/figma/build.mjs --themes=ember,material,excel\n```\n\nThen, in the Figma desktop app, open an empty design file, choose\nPlugins > Development > Import plugin from manifest, pick\n`tools/figma/dist/manifest.json`, and run it. `tools/figma/README.md` covers\nplan limits and how the build is checked.\n"
4522
+ },
4508
4523
  {
4509
4524
  "slug": "help/filtering/advanced-filter",
4510
4525
  "path": "docs/help/filtering/advanced-filter.md",
@@ -4650,7 +4665,7 @@ export const docs = [
4650
4665
  "path": "docs/help/headless/virtualization.md",
4651
4666
  "title": "Headless virtualization",
4652
4667
  "section": "Headless",
4653
- "markdown": "# Headless virtualization\n\n`<SvGrid>` virtualizes rows and columns for you. When you render your own\nmarkup, you virtualize yourself with the same engine SvGrid uses -\n`createSvelteVirtualizer` (reactive) or `createVirtualizer` (framework-agnostic).\nOnly the rows in view get DOM nodes, so 100k rows stay smooth.\n\n<div data-docs-demo=\"187-headless-virtual\" data-height=\"500\"></div>\n\n## The idea\n\nA virtualizer answers two questions as the user scrolls: **which items are\nvisible**, and **how far to offset them**. You give it the item `count`, an\n`estimateSize` (row height), and the `viewportHeight`; it gives you the virtual\nitems to render plus the total size to reserve. You feed it the live scroll\nposition with `setScrollOffset`, and read `version` so your derived values\nrecompute.\n\n```svelte\n<script lang=\"ts\">\n import {\n createSvGrid,\n createCoreRowModel,\n createSvelteVirtualizer,\n tableFeatures,\n type ColumnDef,\n } from '@svgrid/grid/core'\n\n type Row = { id: number; name: string; score: number }\n const data: Row[] = Array.from({ length: 100_000 }, (_, i) => ({\n id: i, name: `Item ${i}`, score: (i * 37) % 1000,\n }))\n const features = tableFeatures({}) // core only - nothing to sort or filter\n const columns: ColumnDef<typeof features, Row>[] = [\n { field: 'name', header: 'Name' },\n { field: 'score', header: 'Score' },\n ]\n\n const table = createSvGrid({\n _features: features,\n _rowModels: { coreRowModel: createCoreRowModel<Row>() },\n data, columns,\n })\n const rows = table.getRowModel().rows\n\n const ROW_H = 34\n const VIEWPORT_H = 420\n const virtualizer = createSvelteVirtualizer({\n count: rows.length, // a number (call setOptions({ count }) when it changes)\n estimateSize: ROW_H, // number, or (index) => px for variable heights\n overscan: 8,\n viewportHeight: VIEWPORT_H,\n })\n\n // Reading `version` makes these recompute whenever the virtualizer updates.\n const items = $derived.by(() => { virtualizer.version; return virtualizer.getVirtualItems() })\n const totalSize = $derived.by(() => { virtualizer.version; return virtualizer.getTotalSize() })\n\n const onScroll = (e: Event) =>\n virtualizer.setScrollOffset((e.currentTarget as HTMLElement).scrollTop)\n</script>\n```\n\n## Render only what's visible\n\nReserve the full scroll height with a spacer, then absolutely-position each\nvisible row at its `start` offset:\n\n```svelte\n<div style={`height: ${VIEWPORT_H}px; overflow: auto; position: relative;`} onscroll={onScroll}>\n <!-- Spacer reserves the full height so the scrollbar is correct. -->\n <div style={`height: ${totalSize}px; position: relative;`}>\n {#each items as vi (vi.key)}\n {@const row = rows[vi.index].original as Row}\n <div style={`position: absolute; top: 0; left: 0; width: 100%;\n height: ${ROW_H}px; transform: translateY(${vi.start}px);`}>\n {row.name} - {row.score}\n </div>\n {/each}\n </div>\n</div>\n```\n\n`getVirtualItems()` returns only the ~20 rows in view (plus `overscan`), each\nwith `index`, `start` (px offset), `size`, and `key`. As the user scrolls,\n`setScrollOffset` bumps `version` and the list re-computes; the other 99,980\nrows never touch the DOM.\n\n## Columns too\n\nFor very wide grids, `createColumnVirtualizer` does the same across leaf columns\n(horizontal). Drive it with `setScrollOffset(scrollLeft)` on the container's\nhorizontal scroll:\n\n```ts\nconst colVirtualizer = createColumnVirtualizer({\n count: leafColumns.length,\n viewportWidth: containerWidth,\n estimateSize: (i) => leafColumns[i].getSize(),\n overscan: 3,\n})\n```\n\n## Which one to use\n\n| Export | Use when |\n| --- | --- |\n| `createSvelteVirtualizer` | Inside a Svelte component - exposes a reactive `version` to re-derive from. |\n| `createVirtualizer` | Framework-agnostic - a non-Svelte custom layer / worker (subscribe manually). |\n| `createColumnVirtualizer` | Horizontal (column) virtualization. |\n\n`VirtualItem`, `VirtualizerOptions`, and `VirtualizerState` are exported for\ntype annotations.\n\n## It is already on\n\nRow virtualization is the default, which is why a large grid needs no\nconfiguration to stay responsive. The DOM holds the visible window, not the row\ncount - open the inspector on the grid below and you will find a few dozen rows,\nnot five thousand.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Row = { id: number; label: string; n: number }\n\n const many: Row[] = Array.from({ length: 5000 }, (_, i) => ({\n id: i,\n label: 'Row ' + i,\n n: (i * 37) % 1000,\n }))\n\n const columns: GridColumns<Row> = [\n { field: 'label', header: 'Label', width: 200 },\n { field: 'n', header: 'Value', width: 120 },\n ]\n</script>\n\n<SvGrid data={many} {columns} sortable />\n```\n\n\n## Columns need their own opt-in\n\nRow virtualization still renders every column of every visible row, so a wide\ngrid stays slow until you add `columnVirtualization`. The trade is real:\noff-screen columns are not in the DOM, so anything that walks the row's cells -\na stylesheet selecting the nth child, a screenshot test - sees only the window.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Wide = Record<string, number>\n\n const names = Array.from({ length: 60 }, (_, i) => 'c' + i)\n\n const wide: Wide[] = Array.from({ length: 2000 }, (_, r) => {\n const row: Wide = { id: r }\n names.forEach((name, c) => (row[name] = (r * (c + 1)) % 500))\n return row\n })\n\n const columns: GridColumns<Wide> = names.map((name) => ({\n field: name,\n header: name.toUpperCase(),\n width: 90,\n }))\n</script>\n\n<SvGrid data={wide} {columns} columnVirtualization />\n```\n\n## See also\n\n- [Build a table from scratch](./build-a-table.md) - render non-virtualized first\n- [Row models](./row-models.md)\n- [Benchmarks](../benchmarks.md) - the 1M-row numbers\n"
4668
+ "markdown": "# Headless virtualization\n\n`<SvGrid>` virtualizes rows and columns for you. When you render your own\nmarkup, you virtualize yourself with the same engine SvGrid uses -\n`createSvelteVirtualizer` (reactive) or `createVirtualizer` (framework-agnostic).\nOnly the rows in view get DOM nodes, so 100k rows stay smooth.\n\n<div data-docs-demo=\"187-headless-virtual\" data-height=\"500\"></div>\n\n## The idea\n\nA virtualizer answers two questions as the user scrolls: **which items are\nvisible**, and **how far to offset them**. You give it the item `count`, an\n`estimateSize` (row height), and the `viewportHeight`; it gives you the virtual\nitems to render plus the total size to reserve. You feed it the live scroll\nposition with `setScrollOffset`, and read `version` so your derived values\nrecompute.\n\n```svelte\n<script lang=\"ts\">\n import {\n createSvGrid,\n createCoreRowModel,\n createSvelteVirtualizer,\n tableFeatures,\n type ColumnDef,\n } from '@svgrid/grid/core'\n\n type Row = { id: number; name: string; score: number }\n const data: Row[] = Array.from({ length: 100_000 }, (_, i) => ({\n id: i, name: `Item ${i}`, score: (i * 37) % 1000,\n }))\n const features = tableFeatures({}) // core only - nothing to sort or filter\n const columns: ColumnDef<typeof features, Row>[] = [\n { field: 'name', header: 'Name' },\n { field: 'score', header: 'Score' },\n ]\n\n const table = createSvGrid({\n _features: features,\n _rowModels: { coreRowModel: createCoreRowModel<Row>() },\n data, columns,\n })\n const rows = table.getRowModel().rows\n\n const ROW_H = 34\n const VIEWPORT_H = 420\n const virtualizer = createSvelteVirtualizer({\n count: rows.length, // a number (call setOptions({ count }) when it changes)\n estimateSize: ROW_H, // number, or (index) => px for variable heights\n overscan: 8,\n viewportHeight: VIEWPORT_H,\n })\n\n // Reading `version` makes these recompute whenever the virtualizer updates.\n const items = $derived.by(() => { virtualizer.version; return virtualizer.getVirtualItems() })\n const totalSize = $derived.by(() => { virtualizer.version; return virtualizer.getTotalSize() })\n\n const onScroll = (e: Event) =>\n virtualizer.setScrollOffset((e.currentTarget as HTMLElement).scrollTop)\n</script>\n```\n\n## Render only what's visible\n\nReserve the full scroll height with a spacer, then absolutely-position each\nvisible row at its `start` offset:\n\n```svelte\n<div style={`height: ${VIEWPORT_H}px; overflow: auto; position: relative;`} onscroll={onScroll}>\n <!-- Spacer reserves the full height so the scrollbar is correct. -->\n <div style={`height: ${totalSize}px; position: relative;`}>\n {#each items as vi (vi.key)}\n {@const row = rows[vi.index].original as Row}\n <div style={`position: absolute; top: 0; left: 0; width: 100%;\n height: ${ROW_H}px; transform: translateY(${vi.start}px);`}>\n {row.name} - {row.score}\n </div>\n {/each}\n </div>\n</div>\n```\n\n`getVirtualItems()` returns only the ~20 rows in view (plus `overscan`), each\nwith `index`, `start` (px offset), `size`, and `key`. As the user scrolls,\n`setScrollOffset` bumps `version` and the list re-computes; the other 99,980\nrows never touch the DOM.\n\n## Columns too\n\nFor very wide grids, `createColumnVirtualizer` does the same across leaf columns\n(horizontal). Drive it with `setScrollOffset(scrollLeft)` on the container's\nhorizontal scroll:\n\n```ts\nconst colVirtualizer = createColumnVirtualizer({\n count: leafColumns.length,\n viewportWidth: containerWidth,\n estimateSize: (i) => leafColumns[i].getSize(),\n overscan: 3,\n})\n```\n\n## Moving the window less often\n\nTwo options shape how the window follows a scroll. `overscanBehind` keeps\nfewer items on the side the scroll is leaving, while the full `overscan`\nstays ahead of it. `overscanMin` keeps the window where it is while at least\nthat many items are still rendered ahead of the scroll, and moves it only\nwhen the scroll runs past them, with the full `overscan` ahead again:\n\n```ts\nconst colVirtualizer = createColumnVirtualizer({\n count: leafColumns.length,\n viewportWidth: containerWidth,\n estimateSize: (i) => leafColumns[i].getSize(),\n overscan: 5, // ahead of the scroll right after a move\n overscanBehind: 1, // behind it\n overscanMin: 2, // never fewer ahead; the window moves 4 items at a time\n})\n```\n\nMoving a column window touches every rendered row, about the same work\nwhether one column enters or four, so a few larger moves cost less than many\none-column moves, and the scroll frames between moves change nothing.\n`<SvGrid>` runs its column window this way. A window built at rest, or after\na jump to items outside the rendered window, carries `overscanMin`.\n\nWhile the window holds, `version` does not change and `getState().scrollOffset`\nkeeps the offset of the last move. Read the scroll position from your\ncontainer if something else has to follow it every frame.\n\n## Which one to use\n\n| Export | Use when |\n| --- | --- |\n| `createSvelteVirtualizer` | Inside a Svelte component - exposes a reactive `version` to re-derive from. |\n| `createVirtualizer` | Framework-agnostic - a non-Svelte custom layer / worker (subscribe manually). |\n| `createColumnVirtualizer` | Horizontal (column) virtualization. |\n\n`VirtualItem`, `VirtualizerOptions`, and `VirtualizerState` are exported for\ntype annotations.\n\n## It is already on\n\nRow virtualization is the default, which is why a large grid needs no\nconfiguration to stay responsive. The DOM holds the visible window, not the row\ncount - open the inspector on the grid below and you will find a few dozen rows,\nnot five thousand.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Row = { id: number; label: string; n: number }\n\n const many: Row[] = Array.from({ length: 5000 }, (_, i) => ({\n id: i,\n label: 'Row ' + i,\n n: (i * 37) % 1000,\n }))\n\n const columns: GridColumns<Row> = [\n { field: 'label', header: 'Label', width: 200 },\n { field: 'n', header: 'Value', width: 120 },\n ]\n</script>\n\n<SvGrid data={many} {columns} sortable />\n```\n\n\n## Columns need their own opt-in\n\nRow virtualization still renders every column of every visible row, so a wide\ngrid stays slow until you add `columnVirtualization`. The trade is real:\noff-screen columns are not in the DOM, so anything that walks the row's cells -\na stylesheet selecting the nth child, a screenshot test - sees only the window.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Wide = Record<string, number>\n\n const names = Array.from({ length: 60 }, (_, i) => 'c' + i)\n\n const wide: Wide[] = Array.from({ length: 2000 }, (_, r) => {\n const row: Wide = { id: r }\n names.forEach((name, c) => (row[name] = (r * (c + 1)) % 500))\n return row\n })\n\n const columns: GridColumns<Wide> = names.map((name) => ({\n field: name,\n header: name.toUpperCase(),\n width: 90,\n }))\n</script>\n\n<SvGrid data={wide} {columns} columnVirtualization />\n```\n\n## See also\n\n- [Build a table from scratch](./build-a-table.md) - render non-virtualized first\n- [Row models](./row-models.md)\n- [Benchmarks](../benchmarks.md) - the 1M-row numbers\n"
4654
4669
  },
4655
4670
  {
4656
4671
  "slug": "help/i18n-rtl",
@@ -4993,7 +5008,7 @@ export const docs = [
4993
5008
  "path": "docs/help/rows/styling-rows.md",
4994
5009
  "title": "Styling rows",
4995
5010
  "section": "Rows",
4996
- "markdown": "# Styling rows\n\nRows are `<tr role=\"row\">` elements inside the grid table. Style them with\nplain CSS.\n<div data-docs-demo=\"62-conditional-styling\" data-height=\"540\"></div>\n\n## Zebra striping\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190, editorType: 'text' },\n { field: 'department', header: 'Department', width: 150, editorType: 'text' },\n { field: 'city', header: 'City', width: 130, editorType: 'text' },\n { field: 'age', header: 'Age', width: 80, editorType: 'number' },\n { field: 'salary', header: 'Salary', width: 130, editorType: 'number', format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```css\ntable[role='grid'] tbody tr:nth-child(even) {\n background: var(--sg-row-alt-bg);\n}\n```\n\n## Hover\n\n```css\ntable[role='grid'] tbody tr:hover {\n background: var(--sg-row-hover-bg);\n}\n```\n\n## Selection\n\nA selected row carries `aria-selected=\"true\"`:\n\n```css\ntable[role='grid'] tbody tr[aria-selected='true'] {\n background: var(--sg-selection-bg);\n}\n```\n\n## Conditional row styling\n\n`<SvGrid>` accepts a `rowClass` callback. Return a string, an array\nof strings, or a `Record<string, boolean>`, and the classes are\nadded to the `<tr>`:\n\n```svelte\n<SvGrid\n data={rows}\n {columns}\n features={features}\n rowClass={({ row }) => ({\n 'is-overdue': row.dueDate < new Date().toISOString().slice(0, 10),\n 'is-cancelled': row.status === 'cancelled',\n })}\n/>\n\n<style>\n :global(tr.is-overdue .sv-grid-cell) { background: rgba(220, 38, 38, 0.06); }\n :global(tr.is-cancelled .sv-grid-cell) { color: var(--sg-muted); text-decoration: line-through; }\n</style>\n```\n\nThe callback receives `{ row, rowIndex }` - the un-mutated source\nrow + its data-array index. Runs per visible row on every render, so\nkeep the body cheap (string lookups, equality checks - no `.find()`\nover the whole dataset).\n\nFor one-cell tints, use `cellClass` on the column def - same shape,\ncalled per cell with the standard `CellContext`. See\n[Styling cells](../cells/styling-cells.md).\n\n## CSS custom properties\n\nThe gallery defines these tokens - override at `:root` or on the grid host:\n\n```\n--sg-bg grid background\n--sg-fg grid foreground\n--sg-border cell borders\n--sg-header-bg header background\n--sg-header-fg header foreground\n--sg-row-alt-bg even-row background\n--sg-row-hover-bg hover background\n--sg-selection-bg selected-row background\n--sg-focus-ring focus outline (box-shadow)\n--sg-accent primary accent (sort arrow, etc)\n```\n\n## More examples\n\n### Zebra rows\n\nThe `zebraRows` prop stripes every other DATA row with the theme's --sg-row-alt-bg token. Pinned, group, detail and summary rows keep their single background, so a pinned total row still reads as one solid band. The stripe follows whatever preset or dark mode the page is on.\n\n<div data-docs-demo=\"174-zebra-rows\" data-height=\"460\"></div>\n\n## Try it\n\n`rowClass` receives the row and its index and returns whatever `class:` accepts -\na string, a list, or an object of conditions. Driving it from the data rather\nthan the index is what keeps the styling correct after a sort.\n\n```svelte {runnable}\n<SvGrid\n data={people}\n {columns}\n sortable\n rowClass={({ row }) => ({\n 'is-senior': row.age >= 50,\n 'is-costly': row.salary > 160000,\n })}\n/>\n\n<style>\n :global(.is-senior) { font-weight: 600; }\n :global(.is-costly) { background: color-mix(in srgb, #f59e0b 12%, transparent); }\n</style>\n```\n\n## See also\n\n- [Row height](./row-height.md)\n- [Custom cells](../cells/cell-components.md)\n"
5011
+ "markdown": "# Styling rows\n\nRows are `<tr role=\"row\">` elements inside the grid table. Style them with\nplain CSS.\n<div data-docs-demo=\"62-conditional-styling\" data-height=\"540\"></div>\n\n## Zebra striping\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190, editorType: 'text' },\n { field: 'department', header: 'Department', width: 150, editorType: 'text' },\n { field: 'city', header: 'City', width: 130, editorType: 'text' },\n { field: 'age', header: 'Age', width: 80, editorType: 'number' },\n { field: 'salary', header: 'Salary', width: 130, editorType: 'number', format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```css\ntable[role='grid'] tbody tr:nth-child(even) {\n background: var(--sg-row-alt-bg);\n}\n```\n\n## Hover\n\n```css\ntable[role='grid'] tbody tr:hover {\n background: var(--sg-row-hover-bg);\n}\n```\n\n## Selection\n\nA selected row carries `aria-selected=\"true\"`:\n\n```css\ntable[role='grid'] tbody tr[aria-selected='true'] {\n background: var(--sg-selection-bg);\n}\n```\n\n## Conditional row styling\n\n`<SvGrid>` accepts a `rowClass` callback. Return a string, an array\nof strings, or a `Record<string, boolean>`, and the classes are\nadded to the `<tr>`:\n\n```svelte\n<SvGrid\n data={rows}\n {columns}\n features={features}\n rowClass={({ row }) => ({\n 'is-overdue': row.dueDate < new Date().toISOString().slice(0, 10),\n 'is-cancelled': row.status === 'cancelled',\n })}\n/>\n\n<style>\n :global(tr.is-overdue .sv-grid-cell) { background: rgba(220, 38, 38, 0.06); }\n :global(tr.is-cancelled .sv-grid-cell) { color: var(--sg-muted); text-decoration: line-through; }\n</style>\n```\n\nThe callback receives `{ row, rowIndex }` - the un-mutated source\nrow + its data-array index. Runs per visible row on every render, so\nkeep the body cheap (string lookups, equality checks - no `.find()`\nover the whole dataset).\n\nFor one-cell tints, use `cellClass` on the column def - same shape,\ncalled per cell with the standard `CellContext`. See\n[Styling cells](../cells/styling-cells.md).\n\n## Row layout\n\nThe markup is a `<table>`, but each `<tr>` is laid out as a flex row of\nfixed-width cells (`display: flex`), not by the table algorithm. On a\nhorizontal scroll the grid adds and removes a column of cells every frame.\nA table re-lays every row for that; a flex row re-lays only its own cells.\nOn the 503 big-data demo at 10,000 columns that is about 1 ms of layout a\nframe instead of 2.7 ms.\n\nWhat this changes for your CSS:\n\n- A body cell's content sits in `<span class=\"sv-grid-cell-line\">`, a\n block that fills the cell. `text-align` on the cell still aligns it, and\n the ellipsis lives on that span.\n- `vertical-align` on a cell does nothing. Cells centre their content with\n `align-items: center`; set `align-items` on the cell to change it.\n- A row's `height` is exact, not a minimum. A row is as tall as\n `rowHeight` says, because that is where the virtualizer places it, and\n content taller than that is clipped instead of growing the row. Raise\n `rowHeight` for taller content, or set `autoRowHeight` to let each row\n grow to fit.\n\n`rowLayout=\"table\"` brings back table layout. The grid uses it on its own\nwhen `mergedCells` holds a merge with `rowSpan` above 1, since a cell\nspanning rows needs the table algorithm, and under the\n`use:spreadsheetLayout` action.\n\n```css\n/* Top-align the cells of one grid. */\n.my-grid .sv-grid-cell {\n align-items: flex-start;\n}\n```\n\nBody rows are not CSS-contained by default. `--sg-row-contain` sets a\n`contain` value on every body row if your app wants one:\n\n```css\n.my-grid {\n --sg-row-contain: layout paint;\n}\n```\n\nWe measured `layout paint` and left it off: each contained row becomes a\nseparate clipped piece of paint, and Chrome redoes its layer work for\nevery one of them on each scroll frame, which doubled the main-thread\nwork of a horizontal scroll on a 1,000-column grid. It also makes a row\nthe containing block for `position: fixed` content inside it.\n\n## CSS custom properties\n\nThe gallery defines these tokens - override at `:root` or on the grid host:\n\n```\n--sg-bg grid background\n--sg-fg grid foreground\n--sg-border cell borders\n--sg-header-bg header background\n--sg-header-fg header foreground\n--sg-row-alt-bg even-row background\n--sg-row-hover-bg hover background\n--sg-selection-bg selected-row background\n--sg-focus-ring focus outline (box-shadow)\n--sg-accent primary accent (sort arrow, etc)\n```\n\n## More examples\n\n### Zebra rows\n\nThe `zebraRows` prop stripes every other DATA row with the theme's --sg-row-alt-bg token. Pinned, group, detail and summary rows keep their single background, so a pinned total row still reads as one solid band. The stripe follows whatever preset or dark mode the page is on.\n\n<div data-docs-demo=\"174-zebra-rows\" data-height=\"460\"></div>\n\n## Try it\n\n`rowClass` receives the row and its index and returns whatever `class:` accepts -\na string, a list, or an object of conditions. Driving it from the data rather\nthan the index is what keeps the styling correct after a sort.\n\n```svelte {runnable}\n<SvGrid\n data={people}\n {columns}\n sortable\n rowClass={({ row }) => ({\n 'is-senior': row.age >= 50,\n 'is-costly': row.salary > 160000,\n })}\n/>\n\n<style>\n :global(.is-senior) { font-weight: 600; }\n :global(.is-costly) { background: color-mix(in srgb, #f59e0b 12%, transparent); }\n</style>\n```\n\n## See also\n\n- [Row height](./row-height.md)\n- [Custom cells](../cells/cell-components.md)\n"
4997
5012
  },
4998
5013
  {
4999
5014
  "slug": "help/rows/transactions",
@@ -5105,7 +5120,7 @@ export const docs = [
5105
5120
  "path": "docs/help/server/server-row-model.md",
5106
5121
  "title": "Server-Side Row Model (SSRM)",
5107
5122
  "section": "Server data",
5108
- "markdown": "# Server-Side Row Model (SSRM)\n\nWhen the data lives on the server - millions of rows in a database - the grid\nshould hold only the page on screen and push sorting, filtering, and paging to\nthe backend. SvGrid packages this as **one datasource contract**: you implement\na single async `getRows`, and `createServerDataSource` owns the request\nlifecycle (paging, sort, filter, race-safety, writes).\n\nNew to the model? [Server-Side Row Model: a walkthrough](./row-model-walkthrough.md) builds it up one step at a time against a server written on the page; this page is the contract in full.\n\n![The createServerDataSource request lifecycle: setSort, setFilter, and setPage trigger a fetch that tags each getRows call with a monotonic request id, and only the latest request lands as a ServerState of rows, total, loading, saving, and error that the grid renders from.](/docs-media/server-controller-state.svg)\n\n<div data-docs-demo=\"148-server-row-model\" data-height=\"480\"></div>\n\n> **Scope.** `createServerDataSource` is the free, flat row model: one page at\n> a time in `page` mode, or one long block-cached list in\n> [`infinite` mode](./server-infinite-scroll.md). Grouping, tree data, pivot,\n> transactions and selection across unloaded rows are the Enterprise\n> [Server-Side Row Model](./server-grouping.md), built on the same contract.\n> See [what this does not do](#what-this-does-not-do).\n\n## Quick start\n\n```ts\nimport { createServerDataSource, type ServerDataSource } from '@svgrid/grid'\n\nconst source: ServerDataSource<Row> = {\n async getRows({ startRow, endRow, sortModel, filterModel }) {\n const res = await fetch('/api/rows', {\n method: 'POST',\n body: JSON.stringify({ startRow, endRow, sortModel, filterModel }),\n })\n const { rows, total } = await res.json()\n return { rows, rowCount: total } // rowCount = total AFTER filtering\n },\n}\n\nlet view = $state(/* ServerState */)\nconst ctl = createServerDataSource(source, { pageSize: 50, onChange: (s) => (view = s) })\nctl.refresh()\n```\n\n## The request your server receives\n\nEvery fetch calls `getRows(request)` with this exact shape:\n\n```ts\ntype ServerRequest = {\n startRow: number // first row wanted, inclusive (= pageIndex * pageSize)\n endRow: number // one past the last row wanted, exclusive\n pageIndex: number\n pageSize: number\n sortModel: ServerSortModel\n filterModel: ServerFilterModel\n}\n```\n\n`startRow` / `endRow` are the **page** bounds (they map to `OFFSET` / `LIMIT`),\nnot block-cache bounds. Return `{ rows, rowCount }` where `rowCount` is the total\ncount **after filtering** - the pager needs it to compute the last page.\n\nEvery request also carries `signal`, an `AbortSignal` the grid aborts when it\nno longer wants the answer: a newer page superseded this one, a block was\npurged or scrolled out of the cache, a group was collapsed, the controller\nwas disposed. Hand it to `fetch(url, { signal })` and a query the user has\nscrolled past is cancelled at the server instead of completing for nothing;\nthe grid ignores the rejection the abort produces. The SvelteKit transport\ndoes this for you and keeps the signal out of the posted body.\n\n### The sort model\n\n```ts\ntype ServerSortModel = Array<{ id: string; desc: boolean }>\n```\n\nMulti-column, in priority order. So this:\n\n```json\n[{ \"id\": \"lastName\", \"desc\": false }, { \"id\": \"age\", \"desc\": true }]\n```\n\nmaps to `ORDER BY last_name ASC, age DESC`.\n\n### The filter model\n\n```ts\ntype ServerFilterModel = {\n global?: string // the quick-filter search box\n columns?: Record<string, { // keyed by column id\n operator: string // equals | contains | startsWith | greaterThan | lessThan | between | isBlank\n value: string\n valueTo?: string // second bound, for `between`\n selectedValues?: string[] // set-filter (facet checklist) selection\n }>\n}\n```\n\nA populated model looks like:\n\n```json\n{\n \"global\": \"berlin\",\n \"columns\": {\n \"status\": { \"operator\": \"equals\", \"value\": \"active\" },\n \"age\": { \"operator\": \"between\", \"value\": \"18\", \"valueTo\": \"65\" },\n \"country\": { \"operator\": \"contains\", \"value\": \"\", \"selectedValues\": [\"DE\", \"FR\"] }\n }\n}\n```\n\n## Translating the model to SQL\n\nMap each column filter's `operator` to a predicate:\n\n| `operator` | SQL |\n| -------------- | -------------------------------- |\n| `equals` | `col = $value` |\n| `contains` | `col ILIKE '%' || $value || '%'` |\n| `startsWith` | `col ILIKE $value || '%'` |\n| `greaterThan` | `col > $value` |\n| `lessThan` | `col < $value` |\n| `between` | `col BETWEEN $value AND $valueTo`|\n| `isBlank` | `col IS NULL OR col = ''` |\n| `selectedValues` present | `col IN ($selectedValues)` |\n\n`global` is a free-text search you `OR` across your searchable columns. Always\nbind values as parameters - never string-concatenate them into SQL.\n\nYou do not have to hand-write that switch. `@svgrid/enterprise` ships\n`normalizeFilters(filterModel)`, which returns `{ predicates, search }` with the\noperators already normalized to `in` / `contains` / `startsWith` / `eq` / `gt` /\n`lt` / `between` - the same helper the built-in REST and SQL sources use. For a\ncomplete, runnable server (route + query builder), follow the\n[Postgres CRUD tutorial](../../enterprise/studio/postgres-grid.md).\n\n## Wiring to the grid\n\nThe controller is a **row model**: hand it to the grid and every seam is\nwired - the rows, the loading flag, external sort and filter, the pager in\npage mode, the visible range and the placeholder rows in infinite mode:\n\n```svelte\n<SvGrid rowModel={ctl} {columns} {features} sortable filterable pageable />\n```\n\nA prop written on the grid beats what the model supplies, so you can adopt\nthe model and still override one piece. The long form spells out the same\nseams by hand, which is also how a model of your own plugs in:\n\n```svelte\n<SvGrid\n data={view.rows}\n {columns} {features}\n sortable filterable\n externalSort externalFilter\n loading={view.loading}\n externalPagination rowCount={view.total} pageIndex={view.pageIndex} pageSize={view.pageSize}\n onSortingChange={(sorting) => ctl.setSort(sorting)}\n onFiltersChange={(f) => ctl.setFilter(f)}\n onPaginationChange={(p) => (p.pageSize !== view.pageSize ? ctl.setPageSize(p.pageSize) : ctl.setPage(p.pageIndex))}\n/>\n```\n\n`externalSort` / `externalFilter` tell the grid to emit intent instead of acting\nlocally; `externalPagination` hands the pager the server's count.\n\n## Infinite mode\n\nPass `mode: 'infinite'` and there are no pages: `state.rows` spans the whole\nresult, placeholder rows stand in for blocks nobody has scrolled to, and\nblocks of `blockSize` rows load as the viewport reaches them, with an LRU\ncap, a concurrency cap, retry for failed blocks and an unknown-count mode.\n[Server-side infinite scroll](./server-infinite-scroll.md) covers it.\n\n## The controller\n\n`createServerDataSource(source, options)` returns:\n\n| Method | Does |\n| ------------------- | -------------------------------------------------------- |\n| `refresh()` | Re-fetch the current page (after a mutation). |\n| `setSort(model)` | New sort, jump to page 0, fetch. |\n| `setFilter(model)` | New filter, jump to page 0, fetch. |\n| `setPage(i)` | Fetch page `i`. |\n| `setPageSize(n)` | New page size, page 0, fetch. |\n| `createRow(input)` | Create through the source, then refresh. Throws if the source has no `createRow`. |\n| `updateRow(id, p)` | Update by id. Optimistic when configured (below). |\n| `deleteRow(id)` | Delete by id. Optimistic when configured. |\n| `getState()` | Snapshot of the current `ServerState`. |\n| `dispose()` | Stop accepting in-flight responses. Call on unmount. |\n\n## The state you render from\n\n`onChange` fires on every transition. The full shape:\n\n```ts\ntype ServerState<Row> = {\n rows: ReadonlyArray<Row>\n total: number // total after filtering (drives the pager)\n loading: boolean // a getRows fetch is in flight\n saving: boolean // a create / update / delete is in flight\n error: unknown // the rejection from the last failed getRows (else null)\n pageIndex: number\n pageSize: number\n pageCount: number\n sortModel: ServerSortModel\n filterModel: ServerFilterModel\n}\n```\n\nBind `loading` to the grid's overlay, `saving` to a toolbar spinner, and check\n`error` to render a retry banner.\n\n## Writes and optimistic updates\n\nImplement whichever of `createRow` / `updateRow` / `deleteRow` your backend\nsupports; the matching controller method calls through and then refreshes the\ncurrent page. Calling one whose source counterpart is missing throws a clear\nerror, so a read-only source stays read-only.\n\nBy default writes are **non-optimistic**: the grid reflects the change only\nafter the follow-up refresh lands. For instant feedback, opt in:\n\n```ts\nconst ctl = createServerDataSource(source, {\n pageSize: 50,\n onChange: (s) => (view = s),\n optimistic: true,\n getRowId: (r) => String(r.id), // required, so a row can be located in the page\n})\n```\n\nNow `updateRow` patches the local row immediately and reconciles with the server\nresult (rolling back on error); `deleteRow` removes it and decrements `total`,\nrestoring both if the server rejects.\n\n## Error handling\n\nIf `getRows` rejects, the controller clears `rows`, stores the rejection in\n`state.error`, and drops `loading` - it never throws into your render. Recover\nby re-fetching:\n\n```svelte\n{#if view.error}\n <div role=\"alert\">\n Could not load rows. <button onclick={() => ctl.refresh()}>Retry</button>\n </div>\n{/if}\n```\n\nA successful fetch clears `error` again.\n\n## Race safety\n\nEvery fetch carries a monotonic id; only the **latest** request is allowed to\nland. A slow response for an old sort/filter can never clobber a newer one - the\nclassic SSRM bug, handled for you. `dispose()` drops everything in flight and\nclears `loading`, so an unmounting component can't leave a stuck spinner.\n\n## What this does not do\n\nBeing honest about the edges so you pick the right tool:\n\n- **Flat rows only.** `createServerDataSource` sends `groupBy: []`. Lazy\n grouping per level, tree data and pivot are `createServerRowModel` in\n `@svgrid/enterprise` - the same `getRows`, with `groupBy`, `groupKeys`,\n `aggregations` and `pivotBy` filled in. See [Server grouping](./server-grouping.md).\n- **A selection is the loaded rows.** Select-all over rows the grid never\n fetched, and a bulk edit sent as a rule, are the Enterprise model's\n [selection](./server-selection.md).\n- **Writes refetch.** A write re-reads the page (or the held blocks); rows are\n not spliced in by hand. Route-addressed [transactions](./server-transactions.md)\n are Enterprise.\n- **Writes are non-optimistic unless you opt in** (above).\n\n## Enterprise: the Server-Side Row Model\n\n`createServerRowModel` from `@svgrid/enterprise` is the same contract with\nthe rest of the request filled in, and the same one-prop wiring:\n\n| Page | What it adds |\n| ---- | ------------ |\n| [Server grouping](./server-grouping.md) | lazy grouping per level, a block cache per level, aggregates, child counts, grand totals, footers, refresh and retry per route, paging over the tree |\n| [Server tree data](./server-tree-data.md) | self-referential trees loaded on expand |\n| [Server pivot](./server-pivot.md) | pivot on the server, columns built from `pivotResultFields`, the pivot designer in server mode |\n| [Server transactions](./server-transactions.md) | add, update and remove rows in a loaded level without a request, sync or batched |\n| [Server selection](./server-selection.md) | select-all as a rule across unloaded rows, a bulk edit by rule |\n| [Worker data source](./worker-data-source.md) | rows already in the browser, queried in a Web Worker so sorting and grouping do not block the page |\n\nThe flagship demo runs all of it over one million rows through one\n`rowModel` prop:\n\n<div data-docs-demo=\"467-server-row-model-1m\" data-height=\"640\"></div>\n\n### A backend written for a callback-style datasource\n\nSome server-side row models hand the datasource a `getRows(params)` that\nanswers through `params.success({ rowData, rowCount })`, with the request\nspelt `rowGroupCols` / `valueCols` / `pivotCols` and a `sortModel` of\n`{ colId, sort }`. `@svgrid/enterprise` maps that shape both ways, so a\nbackend built for it needs no rewrite:\n\n- `adaptCallbackDatasource(ds)` wraps a datasource object of that shape\n for `createServerRowModel` or `createServerDataSource`.\n- `toCallbackRequest(request)` turns a `ServerRequest` into that request\n JSON, for a `getRows` that is a `fetch` against an endpoint which already\n parses it; `fromCallbackRequest` reads it, for an endpoint of yours that\n has to serve such a client too.\n- `toCallbackSelectionState` / `fromCallbackSelectionState` do the same for\n a saved selection rule (`toggledNodes` instead of `toggled`); see\n [Server selection](./server-selection.md).\n\n## More examples\n\n### Server-side pivot\n\nThe pivot designer in server mode over a million rows: Rows become groupBy, Columns pivotBy, Values aggregations, and every applied layout is one request.\n\n<div data-docs-demo=\"468-server-pivot\" data-height=\"560\"></div>\n\n### Server tree data (row model)\n\nA file tree the grid never holds whole: expanding a folder is one getRows with the folder path as groupKeys, per-folder refresh, and a file added or deleted without a refetch.\n\n<div data-docs-demo=\"469-server-tree-data\" data-height=\"560\"></div>\n\n### Server transactions (live feed)\n\nChanges the server already made, applied without a refetch: in-place patches with a flash, batched add and remove transactions addressed by route, and a status for every result.\n\n<div data-docs-demo=\"470-server-transactions\" data-height=\"560\"></div>\n\n### Server selection: select all, minus these\n\nSelect-all as a rule the server counts, flat and per group, with a bulk edit the server applies by rule.\n\n<div data-docs-demo=\"471-server-selection\" data-height=\"560\"></div>\n\n### Server row model to SQL\n\nThe statements planToSql renders for every request, per dialect: rows, count, grand total and the two-step pivot.\n\n<div data-docs-demo=\"472-server-sql-planner\" data-height=\"560\"></div>\n\n### Server grouping: totals, sort and refresh rules\n\nGrand total positions, subtotal footers, open-by-default levels, expand-all over unloaded groups, refresh against purge, and what a sort or a filter re-requests, with the request log.\n\n<div data-docs-demo=\"473-server-grouping-rules\" data-height=\"560\"></div>\n\n### Server row model: CRUD\n\nThe four writes against a backend that says no: a form under the focused region, inline edits with a version check, a refused delete, an undo, `saving`, and optimistic mode against a slow server.\n\n<div data-docs-demo=\"482-server-crud\" data-height=\"560\"></div>\n\n### Server row model: master-detail\n\nA detail panel under any order of the grouped, virtualized tree, fetched from its own endpoint when it opens, with the region row held under the header.\n\n<div data-docs-demo=\"483-server-master-detail\" data-height=\"560\"></div>\n\n### Server grouping (row model)\n\nServer-side grouping through one getRows contract: the request carries groupBy + groupKeys, and createServerRowModel owns the group tree - a block cache per level, lazy expand, per-group sums and a subtotal footer, race-safety - mounted through the one rowModel prop. Leaves arrive by scroll, behind a Load N more row, or paged across the whole tree, and the group panel regroups on the fly. Here a 63,000-row in-memory server behind 200ms latency; the grid holds only the groups you expand. The row model ships in @svgrid/enterprise.\n\n<div data-docs-demo=\"496-server-grouping-model\" data-height=\"560\"></div>\n\n### Server-side infinite scroll\n\n100k-event audit log behind a mock API. Sparse chunked load on scroll; sort + filter + search pushed to the server.\n\n<div data-docs-demo=\"33-server-infinite\" data-height=\"560\"></div>\n\n### Server-side grouping + aggregates\n\nGROUP BY + SUM/AVG pushed to the server; pre-aggregated buckets with on-demand drill-in.\n\n<div data-docs-demo=\"114-server-grouping\" data-height=\"560\"></div>\n\n### Server-side data\n\nSort/filter/page round-tripped to a mock endpoint with debounce + cancel.\n\n<div data-docs-demo=\"09-server-side\" data-height=\"560\"></div>\n\n### Server-side rendering\n\nSvelteKit-style SSR with a sandboxed pre-hydration snapshot.\n\n<div data-docs-demo=\"19-ssr\" data-height=\"460\"></div>\n\n## See also\n\n- [Server-side infinite scroll](./server-infinite-scroll.md) - `mode: 'infinite'`: blocks, placeholders, retry, unknown counts.\n- [Tutorial: a Postgres CRUD grid](../../enterprise/studio/postgres-grid.md) - a complete server route + query builder.\n- [Tutorial: a REST CRUD grid](../../enterprise/studio/rest-grid.md) - the same contract over an existing JSON API.\n- [Data binding](../../enterprise/studio/data-binding.md) - the ServerDataSource contract across every backend.\n- [Server-side data](../server-side-data.md) - the wider server-mode overview.\n"
5123
+ "markdown": "# Server-Side Row Model (SSRM)\n\nWhen the data lives on the server - millions of rows in a database - the grid\nshould hold only the page on screen and push sorting, filtering, and paging to\nthe backend. SvGrid packages this as **one datasource contract**: you implement\na single async `getRows`, and `createServerDataSource` owns the request\nlifecycle (paging, sort, filter, race-safety, writes).\n\nNew to the model? [Server-Side Row Model: a walkthrough](./row-model-walkthrough.md) builds it up one step at a time against a server written on the page; this page is the contract in full.\n\n![The createServerDataSource request lifecycle: setSort, setFilter, and setPage trigger a fetch that tags each getRows call with a monotonic request id, and only the latest request lands as a ServerState of rows, total, loading, saving, and error that the grid renders from.](/docs-media/server-controller-state.svg)\n\n<div data-docs-demo=\"148-server-row-model\" data-height=\"480\"></div>\n\n> **Scope.** `createServerDataSource` is the free, flat row model: one page at\n> a time in `page` mode, or one long block-cached list in\n> [`infinite` mode](./server-infinite-scroll.md). Grouping, tree data, pivot,\n> transactions and selection across unloaded rows are the Enterprise\n> [Server-Side Row Model](./server-grouping.md), built on the same contract.\n> See [what this does not do](#what-this-does-not-do).\n\n## Quick start\n\n```ts\nimport { createServerDataSource, type ServerDataSource } from '@svgrid/grid'\n\nconst source: ServerDataSource<Row> = {\n async getRows({ startRow, endRow, sortModel, filterModel }) {\n const res = await fetch('/api/rows', {\n method: 'POST',\n body: JSON.stringify({ startRow, endRow, sortModel, filterModel }),\n })\n const { rows, total } = await res.json()\n return { rows, rowCount: total } // rowCount = total AFTER filtering\n },\n}\n\nlet view = $state(/* ServerState */)\nconst ctl = createServerDataSource(source, { pageSize: 50, onChange: (s) => (view = s) })\nctl.refresh()\n```\n\n## The request your server receives\n\nEvery fetch calls `getRows(request)` with this exact shape:\n\n```ts\ntype ServerRequest = {\n startRow: number // first row wanted, inclusive (= pageIndex * pageSize)\n endRow: number // one past the last row wanted, exclusive\n pageIndex: number\n pageSize: number\n sortModel: ServerSortModel\n filterModel: ServerFilterModel\n}\n```\n\n`startRow` / `endRow` are the **page** bounds (they map to `OFFSET` / `LIMIT`),\nnot block-cache bounds. Return `{ rows, rowCount }` where `rowCount` is the total\ncount **after filtering** - the pager needs it to compute the last page.\n\nEvery request also carries `signal`, an `AbortSignal` the grid aborts when it\nno longer wants the answer: a newer page superseded this one, a block was\npurged or scrolled out of the cache, a group was collapsed, the controller\nwas disposed. Hand it to `fetch(url, { signal })` and a query the user has\nscrolled past is cancelled at the server instead of completing for nothing;\nthe grid ignores the rejection the abort produces. The SvelteKit transport\ndoes this for you and keeps the signal out of the posted body.\n\n### The sort model\n\n```ts\ntype ServerSortModel = Array<{ id: string; desc: boolean }>\n```\n\nMulti-column, in priority order. So this:\n\n```json\n[{ \"id\": \"lastName\", \"desc\": false }, { \"id\": \"age\", \"desc\": true }]\n```\n\nmaps to `ORDER BY last_name ASC, age DESC`.\n\n### The filter model\n\n```ts\ntype ServerFilterModel = {\n global?: string // the quick-filter search box\n columns?: Record<string, { // keyed by column id\n operator: string // equals | contains | startsWith | greaterThan | lessThan | between | isBlank\n value: string\n valueTo?: string // second bound, for `between`\n selectedValues?: string[] // set-filter (facet checklist) selection\n }>\n}\n```\n\nA populated model looks like:\n\n```json\n{\n \"global\": \"berlin\",\n \"columns\": {\n \"status\": { \"operator\": \"equals\", \"value\": \"active\" },\n \"age\": { \"operator\": \"between\", \"value\": \"18\", \"valueTo\": \"65\" },\n \"country\": { \"operator\": \"contains\", \"value\": \"\", \"selectedValues\": [\"DE\", \"FR\"] }\n }\n}\n```\n\n## Translating the model to SQL\n\nMap each column filter's `operator` to a predicate:\n\n| `operator` | SQL |\n| -------------- | -------------------------------- |\n| `equals` | `col = $value` |\n| `contains` | `col ILIKE '%' || $value || '%'` |\n| `startsWith` | `col ILIKE $value || '%'` |\n| `greaterThan` | `col > $value` |\n| `lessThan` | `col < $value` |\n| `between` | `col BETWEEN $value AND $valueTo`|\n| `isBlank` | `col IS NULL OR col = ''` |\n| `selectedValues` present | `col IN ($selectedValues)` |\n\n`global` is a free-text search you `OR` across your searchable columns. Always\nbind values as parameters - never string-concatenate them into SQL.\n\nYou do not have to hand-write that switch. `@svgrid/enterprise` ships\n`normalizeFilters(filterModel)`, which returns `{ predicates, search }` with the\noperators already normalized to `in` / `contains` / `startsWith` / `eq` / `gt` /\n`lt` / `between` - the same helper the built-in REST and SQL sources use. For a\ncomplete, runnable server (route + query builder), follow the\n[Postgres CRUD tutorial](../../enterprise/studio/postgres-grid.md).\n\n## Wiring to the grid\n\nThe controller is a **row model**: hand it to the grid and every seam is\nwired - the rows, the loading flag, external sort and filter, the pager in\npage mode, the visible range and the placeholder rows in infinite mode:\n\n```svelte\n<SvGrid rowModel={ctl} {columns} {features} sortable filterable pageable />\n```\n\nA prop written on the grid beats what the model supplies, so you can adopt\nthe model and still override one piece. The long form spells out the same\nseams by hand, which is also how a model of your own plugs in:\n\n```svelte\n<SvGrid\n data={view.rows}\n {columns} {features}\n sortable filterable\n externalSort externalFilter\n loading={view.loading}\n externalPagination rowCount={view.total} pageIndex={view.pageIndex} pageSize={view.pageSize}\n onSortingChange={(sorting) => ctl.setSort(sorting)}\n onFiltersChange={(f) => ctl.setFilter(f)}\n onPaginationChange={(p) => (p.pageSize !== view.pageSize ? ctl.setPageSize(p.pageSize) : ctl.setPage(p.pageIndex))}\n/>\n```\n\n`externalSort` / `externalFilter` tell the grid to emit intent instead of acting\nlocally; `externalPagination` hands the pager the server's count.\n\n## Infinite mode\n\nPass `mode: 'infinite'` and there are no pages: `state.rows` spans the whole\nresult, placeholder rows stand in for blocks nobody has scrolled to, and\nblocks of `blockSize` rows load as the viewport reaches them, with an LRU\ncap, a concurrency cap, retry for failed blocks and an unknown-count mode.\n[Server-side infinite scroll](./server-infinite-scroll.md) covers it.\n\n## The controller\n\n`createServerDataSource(source, options)` returns:\n\n| Method | Does |\n| ------------------- | -------------------------------------------------------- |\n| `refresh()` | Re-fetch the current page (after a mutation). |\n| `setSort(model)` | New sort, jump to page 0, fetch. |\n| `setFilter(model)` | New filter, jump to page 0, fetch. |\n| `setPage(i)` | Fetch page `i`. |\n| `setPageSize(n)` | New page size, page 0, fetch. |\n| `createRow(input)` | Create through the source, then refresh. Throws if the source has no `createRow`. |\n| `updateRow(id, p)` | Update by id. Optimistic when configured (below). |\n| `deleteRow(id)` | Delete by id. Optimistic when configured. |\n| `getState()` | Snapshot of the current `ServerState`. |\n| `dispose()` | Stop accepting in-flight responses. Call on unmount. |\n\n## The state you render from\n\n`onChange` fires on every transition. The full shape:\n\n```ts\ntype ServerState<Row> = {\n rows: ReadonlyArray<Row>\n total: number // total after filtering (drives the pager)\n loading: boolean // a getRows fetch is in flight\n saving: boolean // a create / update / delete is in flight\n error: unknown // the rejection from the last failed getRows (else null)\n pageIndex: number\n pageSize: number\n pageCount: number\n sortModel: ServerSortModel\n filterModel: ServerFilterModel\n}\n```\n\nBind `loading` to the grid's overlay, `saving` to a toolbar spinner, and check\n`error` to render a retry banner.\n\n## Writes and optimistic updates\n\nImplement whichever of `createRow` / `updateRow` / `deleteRow` your backend\nsupports; the matching controller method calls through and then refreshes the\ncurrent page. Calling one whose source counterpart is missing throws a clear\nerror, so a read-only source stays read-only.\n\nBy default writes are **non-optimistic**: the grid reflects the change only\nafter the follow-up refresh lands. For instant feedback, opt in:\n\n```ts\nconst ctl = createServerDataSource(source, {\n pageSize: 50,\n onChange: (s) => (view = s),\n optimistic: true,\n getRowId: (r) => String(r.id), // required, so a row can be located in the page\n})\n```\n\nNow `updateRow` patches the local row immediately and reconciles with the server\nresult (rolling back on error); `deleteRow` removes it and decrements `total`,\nrestoring both if the server rejects.\n\n## Error handling\n\nIf `getRows` rejects, the controller clears `rows`, stores the rejection in\n`state.error`, and drops `loading` - it never throws into your render. Recover\nby re-fetching:\n\n```svelte\n{#if view.error}\n <div role=\"alert\">\n Could not load rows. <button onclick={() => ctl.refresh()}>Retry</button>\n </div>\n{/if}\n```\n\nA successful fetch clears `error` again.\n\n## Race safety\n\nEvery fetch carries a monotonic id; only the **latest** request is allowed to\nland. A slow response for an old sort/filter can never clobber a newer one - the\nclassic SSRM bug, handled for you. `dispose()` drops everything in flight and\nclears `loading`, so an unmounting component can't leave a stuck spinner.\n\n## What this does not do\n\nBeing honest about the edges so you pick the right tool:\n\n- **Flat rows only.** `createServerDataSource` sends `groupBy: []`. Lazy\n grouping per level, tree data and pivot are `createServerRowModel` in\n `@svgrid/enterprise` - the same `getRows`, with `groupBy`, `groupKeys`,\n `aggregations` and `pivotBy` filled in. See [Server grouping](./server-grouping.md).\n- **A selection is the loaded rows.** Select-all over rows the grid never\n fetched, and a bulk edit sent as a rule, are the Enterprise model's\n [selection](./server-selection.md).\n- **Writes refetch.** A write re-reads the page (or the held blocks); rows are\n not spliced in by hand. Route-addressed [transactions](./server-transactions.md)\n are Enterprise.\n- **Writes are non-optimistic unless you opt in** (above).\n\n## Enterprise: the Server-Side Row Model\n\n`createServerRowModel` from `@svgrid/enterprise` is the same contract with\nthe rest of the request filled in, and the same one-prop wiring:\n\n| Page | What it adds |\n| ---- | ------------ |\n| [Server grouping](./server-grouping.md) | lazy grouping per level, a block cache per level, aggregates, child counts, grand totals, footers, refresh and retry per route, paging over the tree |\n| [Server tree data](./server-tree-data.md) | self-referential trees loaded on expand |\n| [Server pivot](./server-pivot.md) | pivot on the server, columns built from `pivotResultFields`, the pivot designer in server mode |\n| [Server transactions](./server-transactions.md) | add, update and remove rows in a loaded level without a request, sync or batched |\n| [Server selection](./server-selection.md) | select-all as a rule across unloaded rows, a bulk edit by rule |\n| [Worker data source](./worker-data-source.md) | rows already in the browser, queried in a Web Worker so sorting and grouping do not block the page |\n\nThe flagship demo runs all of it over one million rows through one\n`rowModel` prop:\n\n<div data-docs-demo=\"467-server-row-model-1m\" data-height=\"640\"></div>\n\n### A backend written for a callback-style datasource\n\nSome server-side row models hand the datasource a `getRows(params)` that\nanswers through `params.success({ rowData, rowCount })`, with the request\nspelt `rowGroupCols` / `valueCols` / `pivotCols` and a `sortModel` of\n`{ colId, sort }`. `@svgrid/enterprise` maps that shape both ways, so a\nbackend built for it needs no rewrite:\n\n- `adaptCallbackDatasource(ds)` wraps a datasource object of that shape\n for `createServerRowModel` or `createServerDataSource`.\n- `toCallbackRequest(request)` turns a `ServerRequest` into that request\n JSON, for a `getRows` that is a `fetch` against an endpoint which already\n parses it; `fromCallbackRequest` reads it, for an endpoint of yours that\n has to serve such a client too.\n- `toCallbackSelectionState` / `fromCallbackSelectionState` do the same for\n a saved selection rule (`toggledNodes` instead of `toggled`); see\n [Server selection](./server-selection.md).\n\n## More examples\n\n### Big data: 10M rows x 10K columns\n\nUp to 10,000,000 rows and 10,000 columns in one SvGrid on the Enterprise server row model. A Web Worker sorts (radix, exact), filters, groups and edits a generated dataset and sends back only the rows on screen; the grid builds just those, so its memory and scroll cost stay flat as the row count grows. Pick a size, sort, filter, group, edit a cell, or run the benchmark: every number on the page is measured live in your browser.\n\n<div data-docs-demo=\"503-big-data\" data-height=\"560\"></div>\n\n### Server-side pivot\n\nThe pivot designer in server mode over a million rows: Rows become groupBy, Columns pivotBy, Values aggregations, and every applied layout is one request.\n\n<div data-docs-demo=\"468-server-pivot\" data-height=\"560\"></div>\n\n### Server tree data (row model)\n\nA file tree the grid never holds whole: expanding a folder is one getRows with the folder path as groupKeys, per-folder refresh, and a file added or deleted without a refetch.\n\n<div data-docs-demo=\"469-server-tree-data\" data-height=\"560\"></div>\n\n### Server transactions (live feed)\n\nChanges the server already made, applied without a refetch: in-place patches with a flash, batched add and remove transactions addressed by route, and a status for every result.\n\n<div data-docs-demo=\"470-server-transactions\" data-height=\"560\"></div>\n\n### Server selection: select all, minus these\n\nSelect-all as a rule the server counts, flat and per group, with a bulk edit the server applies by rule.\n\n<div data-docs-demo=\"471-server-selection\" data-height=\"560\"></div>\n\n### Server row model to SQL\n\nThe statements planToSql renders for every request, per dialect: rows, count, grand total and the two-step pivot.\n\n<div data-docs-demo=\"472-server-sql-planner\" data-height=\"560\"></div>\n\n### Server grouping: totals, sort and refresh rules\n\nGrand total positions, subtotal footers, open-by-default levels, expand-all over unloaded groups, refresh against purge, and what a sort or a filter re-requests, with the request log.\n\n<div data-docs-demo=\"473-server-grouping-rules\" data-height=\"560\"></div>\n\n### Server row model: CRUD\n\nThe four writes against a backend that says no: a form under the focused region, inline edits with a version check, a refused delete, an undo, `saving`, and optimistic mode against a slow server.\n\n<div data-docs-demo=\"482-server-crud\" data-height=\"560\"></div>\n\n### Server row model: master-detail\n\nA detail panel under any order of the grouped, virtualized tree, fetched from its own endpoint when it opens, with the region row held under the header.\n\n<div data-docs-demo=\"483-server-master-detail\" data-height=\"560\"></div>\n\n### Server grouping (row model)\n\nServer-side grouping through one getRows contract: the request carries groupBy + groupKeys, and createServerRowModel owns the group tree - a block cache per level, lazy expand, per-group sums and a subtotal footer, race-safety - mounted through the one rowModel prop. Leaves arrive by scroll, behind a Load N more row, or paged across the whole tree, and the group panel regroups on the fly. Here a 63,000-row in-memory server behind 200ms latency; the grid holds only the groups you expand. The row model ships in @svgrid/enterprise.\n\n<div data-docs-demo=\"496-server-grouping-model\" data-height=\"560\"></div>\n\n### Server-side infinite scroll\n\n100k-event audit log behind a mock API. Sparse chunked load on scroll; sort + filter + search pushed to the server.\n\n<div data-docs-demo=\"33-server-infinite\" data-height=\"560\"></div>\n\n### Server-side grouping + aggregates\n\nGROUP BY + SUM/AVG pushed to the server; pre-aggregated buckets with on-demand drill-in.\n\n<div data-docs-demo=\"114-server-grouping\" data-height=\"560\"></div>\n\n### Server-side data\n\nSort/filter/page round-tripped to a mock endpoint with debounce + cancel.\n\n<div data-docs-demo=\"09-server-side\" data-height=\"560\"></div>\n\n### Server-side rendering\n\nSvelteKit-style SSR with a sandboxed pre-hydration snapshot.\n\n<div data-docs-demo=\"19-ssr\" data-height=\"460\"></div>\n\n## See also\n\n- [Server-side infinite scroll](./server-infinite-scroll.md) - `mode: 'infinite'`: blocks, placeholders, retry, unknown counts.\n- [Tutorial: a Postgres CRUD grid](../../enterprise/studio/postgres-grid.md) - a complete server route + query builder.\n- [Tutorial: a REST CRUD grid](../../enterprise/studio/rest-grid.md) - the same contract over an existing JSON API.\n- [Data binding](../../enterprise/studio/data-binding.md) - the ServerDataSource contract across every backend.\n- [Server-side data](../server-side-data.md) - the wider server-mode overview.\n"
5109
5124
  },
5110
5125
  {
5111
5126
  "slug": "help/server/server-selection",
@@ -5273,7 +5288,7 @@ export const docs = [
5273
5288
  "path": "docs/help/tokens.md",
5274
5289
  "title": "Design tokens",
5275
5290
  "section": "Help",
5276
- "markdown": "# Design tokens\n\nEvery visual property the grid exposes for theming is a CSS custom\nproperty (`--sg-*`). Tokens cascade like any other CSS variable -\noverride at `:root`, on a wrapper element, or per-grid; the closest\ndeclaration wins.\n<div data-docs-demo=\"74-theme-integrations\" data-height=\"540\"></div>\n\n> No build step. No theme provider. No design-system lock-in. Set a\n> few `--sg-*` tokens and the grid re-skins immediately.\n\nLive in [demo 74 (Theme integrations)](https://svgrid.com/demos/74-theme-integrations/)\n- shows the same grid rendered through Ant Design, MUI, Fluent, Base\nWeb, and shadcn token sets.\n\n## Core surface tokens\n\nThese are the tokens 95% of integrations override. Stable; safe to\nship in a corporate design system.\n\n| Token | Default (light) | Default (dark) | What it paints |\n| ---------------------- | ---------------------- | ---------------------- | -------------------------------------------------------------------- |\n| `--sg-bg` | `#ffffff` | `#0f172a` | Grid background / cell fill |\n| `--sg-fg` | `#0f172a` | `#f1f5f9` | Primary text colour |\n| `--sg-muted` | `#64748b` | `#94a3b8` | Secondary text (header subtitles, placeholders, \"no data\" message) |\n| `--sg-border` | `#e2e8f0` | `#1e293b` | Cell borders, separators |\n| `--sg-header-bg` | `#f8fafc` | `#1e293b` | Column-header row background |\n| `--sg-header-fg` | inherits `--sg-fg` | inherits `--sg-fg` | Column-header text |\n| `--sg-header-border` | inherits `--sg-border` | inherits `--sg-border` | Line under the column-header row only (body cell borders unaffected) |\n| `--sg-row-alt-bg` | inherits `--sg-bg` | inherits `--sg-bg` | Zebra-stripe background for even-indexed rows |\n| `--sg-row-hover-bg` | `rgba(148,163,184,.10)`| `rgba(148,163,184,.14)`| Hover row background |\n| `--sg-selection-bg` | `rgba(99,102,241,.10)` | `rgba(99,102,241,.18)` | Selected row / selected cell-range fill |\n| `--sg-accent` | `#6366f1` | `#818cf8` | Active-cell ring, primary buttons, sort indicator |\n| `--sg-focus-ring` | `0 0 0 2px rgba(99,102,241,.40)` | same | Box-shadow used for the focus outline |\n\n## Chart series tokens\n\n| Token | Default | What it controls |\n| ------------------------ | --------- | ----------------------------------------- |\n| `--sg-chart-1` | `#2563eb` | First series colour |\n| `--sg-chart-2` | `#16a34a` | Second series |\n| `--sg-chart-3` | `#f59e0b` | Third series |\n| `--sg-chart-4` | `#ef4444` | Fourth series |\n| `--sg-chart-5` | `#8b5cf6` | Fifth series |\n| `--sg-chart-6` | `#0ea5e9` | Sixth series |\n| `--sg-chart-7` | `#ec4899` | Seventh series |\n| `--sg-chart-8` | `#14b8a6` | Eighth series |\n\nThe chart cycles through these in order, so a ninth series reuses the first.\nThey also colour the marks that have no series of their own, such as pie\nslices and tree-map cells.\n\nSet as few as you like; the rest keep their defaults, so a brand primary and\nsecondary is often the whole change:\n\n```css\n:root {\n --sg-chart-1: #7c3aed;\n --sg-chart-2: #db2777;\n}\n```\n\nThe tokens are read where the chart is mounted, so a wrapper can recolour one\nchart without touching the page:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvChart, type ChartSpec } from '@svgrid/grid'\n const spec: ChartSpec = {\n type: 'bar',\n categories: ['Q1', 'Q2', 'Q3', 'Q4'],\n series: [{ label: 'Plan', values: [40, 45, 50, 55] }, { label: 'Actual', values: [38, 49, 47, 61] }, { label: 'Stretch', values: [45, 50, 56, 62] }],\n height: 220,\n }\n</script>\n\n<div style=\"--sg-chart-1: #7c3aed; --sg-chart-2: #db2777; --sg-chart-3: #0d9488\">\n <SvChart {spec} />\n</div>\n```\n\nA spec's own `palette` beats the tokens, and a per-series `color` beats\nboth. The type gallery draws all twenty-nine types from the same eight\ntokens:\n\n<div data-docs-demo=\"435-chart-type-gallery\" data-height=\"600\"></div>\n\nPrecedence runs explicit-to-general: a `color` on the series wins, then\n`spec.palette` (or `charting.palette`), then these tokens, then the built-in\ncolours. The first two are the author speaking about one chart; the tokens are\nthe theme speaking about all of them.\n\n## Layout tokens\n\n| Token | Default | What it controls |\n| ---------------------- | ----------- | ---------------------------------------------------------------------- |\n| `--sg-radius` | `6px` | Border radius on rounded UI inside the grid (chips, badges, menus) |\n| `--sg-font` | inherits | Font family used inside the grid |\n\nRow height is **not** a token. The virtualizer needs the height as a\nnumber before it can lay rows out, so it comes from the `rowHeight`\nprop (default `30`) and is written as an inline style on each row. See\n[Row height](./rows/row-height.md).\n\n## Header typography tokens\n\nThe column-header label is themeable on its own so a preset can match\nits design system's grid header (bold, muted, small caps) without\ntouching the rest of the grid.\n\n| Token | Default | What it controls |\n| ------------------------- | ---------------------- | ------------------------------------------------------------------- |\n| `--sg-header-label-color` | inherits `--sg-header-fg` | Header label colour, when it should differ from the header text |\n| `--sg-header-weight` | `600` | Header label font weight |\n| `--sg-header-size` | inherits | Header label font size |\n| `--sg-header-transform` | `none` | Header label `text-transform` (e.g. `uppercase`) |\n| `--sg-header-tracking` | `normal` | Header label `letter-spacing` |\n| `--sg-header-min-height` | `auto` | Minimum height of a header cell |\n\n## Pinned-column tokens\n\n| Token | Default | What it paints |\n| ---------------------- | ----------- | ---------------------------------------------------------------------- |\n| `--sg-pinned-bg` | inherits `--sg-bg` | Pinned-column cell background (must be opaque to cover scroll under) |\n| `--sg-pinned-header-bg`| inherits `--sg-header-bg` | Pinned-column header background |\n| `--sg-pinned-divider` | `#cbd5e1` | 2px separator between pinned and scrolling region |\n| `--sg-pinned-border` | `color-mix(in oklab, var(--sg-accent) 24%, transparent)` | Top/bottom border of pinned **rows** |\n| `--sg-pinned-shadow-color` | `rgba(15,23,42,.22)` | Colour of the shadow a pinned column casts into the scroll area |\n\n## Surface tokens\n\n| Token | Default | What it paints |\n| ---------------------- | ------------------------- | ------------------------------------------------------------------ |\n| `--sg-bg-subtle` | inherits `--sg-header-bg` | Inset surfaces such as the master-detail region behind a nested grid |\n\n## Input + form tokens\n\n| Token | Default | What it paints |\n| ---------------------- | ---------------------- | -------------------------------------------------------------------- |\n| `--sg-input-bg` | inherits `--sg-bg` | Cell-editor `<input>` background |\n| `--sg-input-border` | inherits `--sg-border` | Cell-editor border |\n| `--sg-rating-on` | `#f59e0b` | Filled star colour in the rating editor |\n| `--sg-rating-empty` | `#cbd5e1` | Empty star colour |\n| `--sg-rating-hover` | `#fbbf24` | Star hover-state colour |\n\n## Scrollbar tokens\n\nThe grid uses a custom scrollbar so it looks consistent across OSes.\n\n| Token | Default |\n| -------------------------------- | ----------- |\n| `--sg-scrollbar-bg` | `transparent` |\n| `--sg-scrollbar-border` | inherits `--sg-border` |\n| `--sg-scrollbar-thumb` | `rgba(148,163,184,.45)` |\n| `--sg-scrollbar-thumb-hover` | `rgba(148,163,184,.65)` |\n| `--sg-scrollbar-thumb-active` | `rgba(148,163,184,.85)` |\n| `--sg-scrollbar-arrow` | inherits `--sg-muted` |\n| `--sg-scrollbar-arrow-hover` | inherits `--sg-fg` |\n| `--sg-scrollbar-arrow-hover-bg` | inherits `--sg-row-hover-bg` |\n| `--sg-scrollbar-arrow-active` | inherits `--sg-accent` |\n| `--sg-scrollbar-arrow-active-bg` | inherits `--sg-row-hover-bg` |\n| `--sg-scrollbar-arrow-disabled` | `rgba(148,163,184,.35)` |\n\n## Pill / status tokens (gallery-defined)\n\nThese are used by several demos but live in the gallery, not the\nlibrary. Copy if you want the same convention; rename freely otherwise.\n\n| Token | Used for |\n| --------------------------- | --------------------------------------- |\n| `--sg-pill-active` | \"Active\" status chip background |\n| `--sg-pill-active-fg` | \"Active\" status chip text |\n| `--sg-pill-pending` | \"Pending\" status chip background |\n| `--sg-pill-pending-fg` | \"Pending\" status chip text |\n| `--sg-pill-inactive` | \"Inactive\" status chip background |\n| `--sg-pill-inactive-fg` | \"Inactive\" status chip text |\n\n## How to override\n\n### Globally (your app's stylesheet)\n\n```css\n:root {\n --sg-accent: #db2777; /* hot pink everywhere */\n --sg-radius: 10px;\n --sg-font: 'Inter', sans-serif;\n}\n```\n\n### Per-grid (Tailwind / inline style)\n\n```svelte\n<div class=\"my-grid\" style=\"--sg-accent: #16a34a\">\n <SvGrid {data} {columns} features={features} />\n</div>\n```\n\n### Dark mode\n\nThe library doesn't ship a built-in dark theme - it reads whatever\nyour app provides. Most apps gate via `[data-theme=\"dark\"]` on\n`<html>`; sv-grid's only requirement is that you override the same\ntoken set under that selector:\n\n```css\n[data-theme=\"dark\"] {\n --sg-bg: #0f172a;\n --sg-fg: #f1f5f9;\n --sg-border: #1e293b;\n --sg-header-bg: #1e293b;\n --sg-row-hover-bg: rgba(148, 163, 184, 0.14);\n --sg-selection-bg: rgba(99, 102, 241, 0.18);\n}\n```\n\nTheme presets ship in\n[demo 74](https://svgrid.com/demos/74-theme-integrations/) for Ant\nDesign, MUI, Fluent, Base Web, and shadcn - each with a light AND a\ndark token bundle ready to copy.\n\n### Built-in design-system presets\n\n`@svgrid/grid` also ships 20 ready-made presets as plain stylesheets - import the\none you want and the grid re-skins immediately, light and dark both included\n(toggle with the same `[data-theme=\"dark\"]` attribute as above):\n\n```css\n@import '@svgrid/grid/themes/material.css';\n```\n\nAvailable ids: `ember`, `shadcn`, `tailwind`, `material`, `excel`, `fluent`,\n`carbon`, `sap`, `salesforce`, `atlassian`, `github`, `antd`, `ag-alpine`,\n`bootstrap`, `vercel`, `linear`, `notion`, `nord`, `dracula`, `catppuccin`.\n\n`ember` is SvGrid's own theme rather than a copy of an external design system:\nwarm neutrals with Svelte orange as the single accent. It is the palette\nsvgrid.com runs on, so one import reproduces the look of the grids on the site.\n\n## Stability promise\n\nCore surface tokens (the first table) are **stable**: they will not\nbe renamed or removed in a minor version. New tokens may be added.\nSee [API stability](./api-stability.md) for the full policy.\n\nLayout / pinned / scrollbar / input tokens follow the same promise.\n\nPill tokens are gallery-defined - the LIBRARY makes no promise about\nthem.\n\n## See also\n\n- [Tailwind integration](./tailwind.md) - how to wire tokens through\n Tailwind's theming layer\n- [Custom cells + themes](https://svgrid.com/demos/10-custom-cells-and-themes/) demo - the canonical token-override example\n- [Theme integrations](https://svgrid.com/demos/74-theme-integrations/) demo - five design-system presets side by side\n"
5291
+ "markdown": "# Design tokens\n\nEvery visual property the grid exposes for theming is a CSS custom\nproperty (`--sg-*`). Tokens cascade like any other CSS variable -\noverride at `:root`, on a wrapper element, or per-grid; the closest\ndeclaration wins.\n<div data-docs-demo=\"74-theme-integrations\" data-height=\"540\"></div>\n\n> No build step. No theme provider. No design-system lock-in. Set a\n> few `--sg-*` tokens and the grid re-skins immediately.\n\nLive in [demo 74 (Theme integrations)](https://svgrid.com/demos/74-theme-integrations/)\n- shows the same grid rendered through Ant Design, MUI, Fluent, Base\nWeb, and shadcn token sets.\n\n## Core surface tokens\n\nThese are the tokens 95% of integrations override. Stable; safe to\nship in a corporate design system.\n\n| Token | Default (light) | Default (dark) | What it paints |\n| ---------------------- | ---------------------- | ---------------------- | -------------------------------------------------------------------- |\n| `--sg-bg` | `#ffffff` | `#0f172a` | Grid background / cell fill |\n| `--sg-fg` | `#0f172a` | `#f1f5f9` | Primary text colour |\n| `--sg-muted` | `#64748b` | `#94a3b8` | Secondary text (header subtitles, placeholders, \"no data\" message) |\n| `--sg-border` | `#e2e8f0` | `#1e293b` | Cell borders, separators |\n| `--sg-header-bg` | `#f8fafc` | `#1e293b` | Column-header row background |\n| `--sg-header-fg` | inherits `--sg-fg` | inherits `--sg-fg` | Column-header text |\n| `--sg-header-border` | inherits `--sg-border` | inherits `--sg-border` | Line under the column-header row only (body cell borders unaffected) |\n| `--sg-row-alt-bg` | inherits `--sg-bg` | inherits `--sg-bg` | Zebra-stripe background for even-indexed rows |\n| `--sg-row-hover-bg` | `rgba(148,163,184,.10)`| `rgba(148,163,184,.14)`| Hover row background |\n| `--sg-selection-bg` | `rgba(99,102,241,.10)` | `rgba(99,102,241,.18)` | Selected row / selected cell-range fill |\n| `--sg-accent` | `#6366f1` | `#818cf8` | Active-cell ring, primary buttons, sort indicator |\n| `--sg-focus-ring` | `0 0 0 2px rgba(99,102,241,.40)` | same | Box-shadow used for the focus outline |\n\n## Chart series tokens\n\n| Token | Default | What it controls |\n| ------------------------ | --------- | ----------------------------------------- |\n| `--sg-chart-1` | `#2563eb` | First series colour |\n| `--sg-chart-2` | `#16a34a` | Second series |\n| `--sg-chart-3` | `#f59e0b` | Third series |\n| `--sg-chart-4` | `#ef4444` | Fourth series |\n| `--sg-chart-5` | `#8b5cf6` | Fifth series |\n| `--sg-chart-6` | `#0ea5e9` | Sixth series |\n| `--sg-chart-7` | `#ec4899` | Seventh series |\n| `--sg-chart-8` | `#14b8a6` | Eighth series |\n\nThe chart cycles through these in order, so a ninth series reuses the first.\nThey also colour the marks that have no series of their own, such as pie\nslices and tree-map cells.\n\nSet as few as you like; the rest keep their defaults, so a brand primary and\nsecondary is often the whole change:\n\n```css\n:root {\n --sg-chart-1: #7c3aed;\n --sg-chart-2: #db2777;\n}\n```\n\nThe tokens are read where the chart is mounted, so a wrapper can recolour one\nchart without touching the page:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvChart, type ChartSpec } from '@svgrid/grid'\n const spec: ChartSpec = {\n type: 'bar',\n categories: ['Q1', 'Q2', 'Q3', 'Q4'],\n series: [{ label: 'Plan', values: [40, 45, 50, 55] }, { label: 'Actual', values: [38, 49, 47, 61] }, { label: 'Stretch', values: [45, 50, 56, 62] }],\n height: 220,\n }\n</script>\n\n<div style=\"--sg-chart-1: #7c3aed; --sg-chart-2: #db2777; --sg-chart-3: #0d9488\">\n <SvChart {spec} />\n</div>\n```\n\nA spec's own `palette` beats the tokens, and a per-series `color` beats\nboth. The type gallery draws all twenty-nine types from the same eight\ntokens:\n\n<div data-docs-demo=\"435-chart-type-gallery\" data-height=\"600\"></div>\n\nPrecedence runs explicit-to-general: a `color` on the series wins, then\n`spec.palette` (or `charting.palette`), then these tokens, then the built-in\ncolours. The first two are the author speaking about one chart; the tokens are\nthe theme speaking about all of them.\n\n## Layout tokens\n\n| Token | Default | What it controls |\n| ---------------------- | ----------- | ---------------------------------------------------------------------- |\n| `--sg-radius` | `6px` | Border radius on rounded UI inside the grid (chips, badges, menus) |\n| `--sg-font` | inherits | Font family used inside the grid |\n\nRow height is **not** a token. The virtualizer needs the height as a\nnumber before it can lay rows out, so it comes from the `rowHeight`\nprop (default `30`) and is written as an inline style on each row. See\n[Row height](./rows/row-height.md).\n\n## Header typography tokens\n\nThe column-header label is themeable on its own so a preset can match\nits design system's grid header (bold, muted, small caps) without\ntouching the rest of the grid.\n\n| Token | Default | What it controls |\n| ------------------------- | ---------------------- | ------------------------------------------------------------------- |\n| `--sg-header-label-color` | inherits `--sg-header-fg` | Header label colour, when it should differ from the header text |\n| `--sg-header-weight` | `600` | Header label font weight |\n| `--sg-header-size` | inherits | Header label font size |\n| `--sg-header-transform` | `none` | Header label `text-transform` (e.g. `uppercase`) |\n| `--sg-header-tracking` | `normal` | Header label `letter-spacing` |\n| `--sg-header-min-height` | `auto` | Minimum height of a header cell |\n\n## Pinned-column tokens\n\n| Token | Default | What it paints |\n| ---------------------- | ----------- | ---------------------------------------------------------------------- |\n| `--sg-pinned-bg` | inherits `--sg-bg` | Pinned-column cell background (must be opaque to cover scroll under) |\n| `--sg-pinned-header-bg`| inherits `--sg-header-bg` | Pinned-column header background |\n| `--sg-pinned-divider` | `#cbd5e1` | 2px separator between pinned and scrolling region |\n| `--sg-pinned-border` | `color-mix(in oklab, var(--sg-accent) 24%, transparent)` | Top/bottom border of pinned **rows** |\n| `--sg-pinned-shadow-color` | `rgba(15,23,42,.22)` | Colour of the shadow a pinned column casts into the scroll area |\n\n## Surface tokens\n\n| Token | Default | What it paints |\n| ---------------------- | ------------------------- | ------------------------------------------------------------------ |\n| `--sg-bg-subtle` | inherits `--sg-header-bg` | Inset surfaces such as the master-detail region behind a nested grid |\n\n## Input + form tokens\n\n| Token | Default | What it paints |\n| ---------------------- | ---------------------- | -------------------------------------------------------------------- |\n| `--sg-input-bg` | inherits `--sg-bg` | Cell-editor `<input>` background |\n| `--sg-input-border` | inherits `--sg-border` | Cell-editor border |\n| `--sg-rating-on` | `#f59e0b` | Filled star colour in the rating editor |\n| `--sg-rating-empty` | `#cbd5e1` | Empty star colour |\n| `--sg-rating-hover` | `#fbbf24` | Star hover-state colour |\n\n## Scrollbar tokens\n\nThe grid uses a custom scrollbar so it looks consistent across OSes.\n\n| Token | Default |\n| -------------------------------- | ----------- |\n| `--sg-scrollbar-bg` | `transparent` |\n| `--sg-scrollbar-border` | inherits `--sg-border` |\n| `--sg-scrollbar-thumb` | `rgba(148,163,184,.45)` |\n| `--sg-scrollbar-thumb-hover` | `rgba(148,163,184,.65)` |\n| `--sg-scrollbar-thumb-active` | `rgba(148,163,184,.85)` |\n| `--sg-scrollbar-arrow` | inherits `--sg-muted` |\n| `--sg-scrollbar-arrow-hover` | inherits `--sg-fg` |\n| `--sg-scrollbar-arrow-hover-bg` | inherits `--sg-row-hover-bg` |\n| `--sg-scrollbar-arrow-active` | inherits `--sg-accent` |\n| `--sg-scrollbar-arrow-active-bg` | inherits `--sg-row-hover-bg` |\n| `--sg-scrollbar-arrow-disabled` | `rgba(148,163,184,.35)` |\n\n## Pill / status tokens (gallery-defined)\n\nThese are used by several demos but live in the gallery, not the\nlibrary. Copy if you want the same convention; rename freely otherwise.\n\n| Token | Used for |\n| --------------------------- | --------------------------------------- |\n| `--sg-pill-active` | \"Active\" status chip background |\n| `--sg-pill-active-fg` | \"Active\" status chip text |\n| `--sg-pill-pending` | \"Pending\" status chip background |\n| `--sg-pill-pending-fg` | \"Pending\" status chip text |\n| `--sg-pill-inactive` | \"Inactive\" status chip background |\n| `--sg-pill-inactive-fg` | \"Inactive\" status chip text |\n\n## How to override\n\n### Globally (your app's stylesheet)\n\n```css\n:root {\n --sg-accent: #db2777; /* hot pink everywhere */\n --sg-radius: 10px;\n --sg-font: 'Inter', sans-serif;\n}\n```\n\n### Per-grid (Tailwind / inline style)\n\n```svelte\n<div class=\"my-grid\" style=\"--sg-accent: #16a34a\">\n <SvGrid {data} {columns} features={features} />\n</div>\n```\n\n### Dark mode\n\nThe library doesn't ship a built-in dark theme - it reads whatever\nyour app provides. Most apps gate via `[data-theme=\"dark\"]` on\n`<html>`; sv-grid's only requirement is that you override the same\ntoken set under that selector:\n\n```css\n[data-theme=\"dark\"] {\n --sg-bg: #0f172a;\n --sg-fg: #f1f5f9;\n --sg-border: #1e293b;\n --sg-header-bg: #1e293b;\n --sg-row-hover-bg: rgba(148, 163, 184, 0.14);\n --sg-selection-bg: rgba(99, 102, 241, 0.18);\n}\n```\n\nTheme presets ship in\n[demo 74](https://svgrid.com/demos/74-theme-integrations/) for Ant\nDesign, MUI, Fluent, Base Web, and shadcn - each with a light AND a\ndark token bundle ready to copy.\n\n### Built-in design-system presets\n\n`@svgrid/grid` also ships 20 ready-made presets as plain stylesheets - import the\none you want and the grid re-skins immediately, light and dark both included\n(toggle with the same `[data-theme=\"dark\"]` attribute as above):\n\n```css\n@import '@svgrid/grid/themes/material.css';\n```\n\nAvailable ids: `ember`, `shadcn`, `tailwind`, `material`, `excel`, `fluent`,\n`carbon`, `sap`, `salesforce`, `atlassian`, `github`, `antd`, `ag-alpine`,\n`bootstrap`, `vercel`, `linear`, `notion`, `nord`, `dracula`, `catppuccin`.\n\n`ember` is SvGrid's own theme rather than a copy of an external design system:\nwarm neutrals with Svelte orange as the single accent. It is the palette\nsvgrid.com runs on, so one import reproduces the look of the grids on the site.\n\n## Stability promise\n\nCore surface tokens (the first table) are **stable**: they will not\nbe renamed or removed in a minor version. New tokens may be added.\nSee [API stability](./api-stability.md) for the full policy.\n\nLayout / pinned / scrollbar / input tokens follow the same promise.\n\nPill tokens are gallery-defined - the LIBRARY makes no promise about\nthem.\n\n## See also\n\n- [Figma design kit](./figma.md) - the same tokens as Figma variables, on\n [Figma Community](https://www.figma.com/community/file/1689644521333526144)\n- [Tailwind integration](./tailwind.md) - how to wire tokens through\n Tailwind's theming layer\n- [Custom cells + themes](https://svgrid.com/demos/10-custom-cells-and-themes/) demo - the canonical token-override example\n- [Theme integrations](https://svgrid.com/demos/74-theme-integrations/) demo - five design-system presets side by side\n"
5277
5292
  },
5278
5293
  {
5279
5294
  "slug": "help/troubleshooting",
@@ -5987,7 +6002,7 @@ export const docs = [
5987
6002
  "path": "docs/help/web-components.md",
5988
6003
  "title": "Web Components & Custom Elements",
5989
6004
  "section": "Help",
5990
- "markdown": "# Web Components & Custom Elements\n\nShip SvGrid as a framework-agnostic web component and use the Svelte 5 data grid\nin React, Vue, Angular, or plain HTML - no Svelte required in the host app.\n\n![React, Vue, Angular, and plain HTML each render the same sv-grid custom element, which wraps one shared SvGrid engine.](/docs-media/grid-web-components.svg)\n\nSvGrid is authored in Svelte 5, but Svelte compiles to standards-based\n[custom elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_components).\n`@svgrid/grid-wc` publishes two of them, prebuilt: drop in one `<script>` tag\nand use `<sv-grid>` like any built-in element. If you searched for a \"web\ncomponent data grid\" or a \"framework-agnostic Svelte table\", this is the\nintegration path.\n\n## Start here\n\n| Page | What it covers |\n| --- | --- |\n| [**React, Vue or Angular**](./web-components/frameworks.md) | **Start here if you use one.** A running project in about thirty seconds, and 27 examples you can open and edit. |\n| [Quick start](./web-components/quick-start.md) | The CDN path, and the attribute-vs-property rule that trips everyone up once. |\n| [`<sv-grid>` reference](./web-components/sv-grid.md) | Every property, attribute and event. Generated from the grid's own types. |\n| [Shadow DOM](./web-components/shadow-dom.md) | `<sv-grid-shadow>`, for pages whose CSS you do not control. |\n| [React](./web-components/react.md) | The `@svgrid/grid-wc/react` component, and why React 18 needs it. |\n| [Vue](./web-components/vue.md) | The `@svgrid/grid-wc/vue` component - no `isCustomElement` config, no `.prop` modifiers. |\n| [Angular](./web-components/angular.md) | The `@svgrid/grid-wc/angular` standalone component, with typed inputs and outputs. |\n| [TypeScript](./web-components/typescript.md) | Typing the element, its properties and its events. |\n| [Enterprise features](./web-components/enterprise.md) | Excel / PDF export, import, print and pivot from a non-Svelte host - and the two views that need a Svelte-aware bundler. |\n| [Limitations](./web-components/limitations.md) | What cannot cross the boundary, and why. |\n\n## Framework wrappers\n\nThe package ships a component for each of the three big frameworks, generated\nfrom the same types as the element, as subpath imports:\n\n| Framework | Import |\n| --- | --- |\n| React | `import { SvGrid } from '@svgrid/grid-wc/react'` |\n| Vue | `import { SvGrid } from '@svgrid/grid-wc/vue'` |\n| Angular | `import { SvGridComponent } from '@svgrid/grid-wc/angular'` |\n\nEach framework is an OPTIONAL peer dependency, so a plain-HTML consumer never\ninstalls any of them, and each wrapper is a couple of KB that reuses the one\nelement bundle rather than shipping a second copy of the grid.\n\n**[Start here](./web-components/frameworks.md)** for the thirty-second path and\nnine runnable examples per framework - each one opens as a full editable\nproject, with no local setup.\n\n## Why a custom element\n\n- **Use it anywhere.** React, Vue, Angular, Solid, Astro, Rails/Laravel\n server-rendered pages, or a plain `<script>` tag. The browser treats\n `<sv-grid>` like any built-in element.\n- **One bundle, no framework lock-in.** Ship a single file to a CDN; consumers\n add one `<script type=\"module\">` and a tag.\n- **The whole grid.** 115 properties and 27 events, so virtualization,\n Excel-style filters, inline editing, grouping, tree data, pinning, pagination\n and the enterprise features are all reachable - not a hand-picked subset. The\n surface is **generated** from `<SvGrid>`'s own `Props` type and CI fails if\n the two drift. Exactly two props are left out, and the\n [reference](./web-components/sv-grid.md) says why.\n\nIf your team *is* on Svelte 5, use the component directly\n([First grid](../getting-started/2-first-grid.md)) - the custom element exists\nto reach non-Svelte hosts, and it costs you the Svelte runtime to do it.\n\n## Quick look\n\n```html\n<script type=\"module\" src=\"https://unpkg.com/@svgrid/grid-wc\"></script>\n\n<sv-grid id=\"grid\" sortable filterable pageable page-size=\"25\"\n style=\"display:block;height:420px\"></sv-grid>\n\n<script type=\"module\">\n const grid = document.getElementById('grid')\n // Arrays and objects are properties; primitives can be attributes.\n grid.columns = [{ field: 'name', header: 'Name' }, { field: 'role', header: 'Role' }]\n grid.data = [{ name: 'Ada', role: 'Engineering' }, { name: 'Alan', role: 'Research' }]\n grid.addEventListener('cellvaluechange', (e) => console.log(e.detail))\n</script>\n```\n\nFull walkthrough in [Quick start](./web-components/quick-start.md).\n\n## Building your own element\n\nThe published package is the recommended path, but the wrapper is about sixty\nlines and you may want a narrower surface, your own tag name, or extra\nbehaviour. The source is `packages/grid-wc/src` in the repository:\n`sv-grid-element.svelte` is the light-DOM wrapper, `GridBody.svelte` is the part\nboth elements share, and `scripts/generate-surface.mjs` writes the prop and\nevent declarations from `<SvGrid>`'s `Props` type.\n\n## See also\n\n- [Enterprise features from the element](./web-components/enterprise.md) -\n export, import, print and pivot need no Svelte in your build; the board and\n scheduler views do.\n- [Tailwind integration](./tailwind.md) - the full `--sg-*` token list.\n"
6005
+ "markdown": "# Web Components & Custom Elements\n\nShip SvGrid as a framework-agnostic web component and use the Svelte 5 data grid\nin React, Vue, Angular, or plain HTML - no Svelte required in the host app.\n\n![React, Vue, Angular, and plain HTML each render the same sv-grid custom element, which wraps one shared SvGrid engine.](/docs-media/grid-web-components.svg)\n\nSvGrid is authored in Svelte 5, but Svelte compiles to standards-based\n[custom elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_components).\n`@svgrid/grid-wc` publishes two of them, prebuilt: drop in one `<script>` tag\nand use `<sv-grid>` like any built-in element. If you searched for a \"web\ncomponent data grid\" or a \"framework-agnostic Svelte table\", this is the\nintegration path.\n\n<!-- tutorial:mk-web-component -->\n<figure class=\"docs-tutorial docs-tutorial-player\" id=\"tutorial-mk-web-component\" data-docs-tutorial=\"mk-web-component\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/mk-web-component.mp4\" poster=\"/tutorials/mk-web-component.poster.webp\" width=\"960\" height=\"540\" controls playsinline preload=\"none\" aria-label=\"A data grid web component: one script tag, any framework, 133 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/mk-web-component.vtt\">Your browser does not play embedded video. <a href=\"/tutorials/mk-web-component.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>A data grid web component: one script tag, any framework</strong> (2:13, with narration). <a href=\"https://www.youtube.com/watch?v=_zWVutDXlVY\" rel=\"noopener\">Watch on YouTube</a></figcaption>\n</figure>\n<!-- /tutorial:mk-web-component -->\n\n## Start here\n\n| Page | What it covers |\n| --- | --- |\n| [**React, Vue or Angular**](./web-components/frameworks.md) | **Start here if you use one.** A running project in about thirty seconds, and 27 examples you can open and edit. |\n| [Quick start](./web-components/quick-start.md) | The CDN path, and the attribute-vs-property rule that trips everyone up once. |\n| [`<sv-grid>` reference](./web-components/sv-grid.md) | Every property, attribute and event. Generated from the grid's own types. |\n| [Shadow DOM](./web-components/shadow-dom.md) | `<sv-grid-shadow>`, for pages whose CSS you do not control. |\n| [React](./web-components/react.md) | The `@svgrid/grid-wc/react` component, and why React 18 needs it. |\n| [Vue](./web-components/vue.md) | The `@svgrid/grid-wc/vue` component - no `isCustomElement` config, no `.prop` modifiers. |\n| [Angular](./web-components/angular.md) | The `@svgrid/grid-wc/angular` standalone component, with typed inputs and outputs. |\n| [TypeScript](./web-components/typescript.md) | Typing the element, its properties and its events. |\n| [Enterprise features](./web-components/enterprise.md) | Excel / PDF export, import, print and pivot from a non-Svelte host - and the two views that need a Svelte-aware bundler. |\n| [Limitations](./web-components/limitations.md) | What cannot cross the boundary, and why. |\n\n## Framework wrappers\n\nThe package ships a component for each of the three big frameworks, generated\nfrom the same types as the element, as subpath imports:\n\n| Framework | Import |\n| --- | --- |\n| React | `import { SvGrid } from '@svgrid/grid-wc/react'` |\n| Vue | `import { SvGrid } from '@svgrid/grid-wc/vue'` |\n| Angular | `import { SvGridComponent } from '@svgrid/grid-wc/angular'` |\n\nEach framework is an OPTIONAL peer dependency, so a plain-HTML consumer never\ninstalls any of them, and each wrapper is a couple of KB that reuses the one\nelement bundle rather than shipping a second copy of the grid.\n\n**[Start here](./web-components/frameworks.md)** for the thirty-second path and\nnine runnable examples per framework - each one opens as a full editable\nproject, with no local setup.\n\n## Why a custom element\n\n- **Use it anywhere.** React, Vue, Angular, Solid, Astro, Rails/Laravel\n server-rendered pages, or a plain `<script>` tag. The browser treats\n `<sv-grid>` like any built-in element.\n- **One bundle, no framework lock-in.** Ship a single file to a CDN; consumers\n add one `<script type=\"module\">` and a tag.\n- **The whole grid.** 116 properties and 27 events, so virtualization,\n Excel-style filters, inline editing, grouping, tree data, pinning, pagination\n and the enterprise features are all reachable - not a hand-picked subset. The\n surface is **generated** from `<SvGrid>`'s own `Props` type and CI fails if\n the two drift. Exactly two props are left out, and the\n [reference](./web-components/sv-grid.md) says why.\n\nIf your team *is* on Svelte 5, use the component directly\n([First grid](../getting-started/2-first-grid.md)) - the custom element exists\nto reach non-Svelte hosts, and it costs you the Svelte runtime to do it.\n\n## Quick look\n\n```html\n<script type=\"module\" src=\"https://unpkg.com/@svgrid/grid-wc\"></script>\n\n<sv-grid id=\"grid\" sortable filterable pageable page-size=\"25\"\n style=\"display:block;height:420px\"></sv-grid>\n\n<script type=\"module\">\n const grid = document.getElementById('grid')\n // Arrays and objects are properties; primitives can be attributes.\n grid.columns = [{ field: 'name', header: 'Name' }, { field: 'role', header: 'Role' }]\n grid.data = [{ name: 'Ada', role: 'Engineering' }, { name: 'Alan', role: 'Research' }]\n grid.addEventListener('cellvaluechange', (e) => console.log(e.detail))\n</script>\n```\n\nFull walkthrough in [Quick start](./web-components/quick-start.md).\n\n## Building your own element\n\nThe published package is the recommended path, but the wrapper is about sixty\nlines and you may want a narrower surface, your own tag name, or extra\nbehaviour. The source is `packages/grid-wc/src` in the repository:\n`sv-grid-element.svelte` is the light-DOM wrapper, `GridBody.svelte` is the part\nboth elements share, and `scripts/generate-surface.mjs` writes the prop and\nevent declarations from `<SvGrid>`'s `Props` type.\n\n## See also\n\n- [Enterprise features from the element](./web-components/enterprise.md) -\n export, import, print and pivot need no Svelte in your build; the board and\n scheduler views do.\n- [Tailwind integration](./tailwind.md) - the full `--sg-*` token list.\n"
5991
6006
  },
5992
6007
  {
5993
6008
  "slug": "help/web-components/angular",
@@ -6008,7 +6023,7 @@ export const docs = [
6008
6023
  "path": "docs/help/web-components/frameworks.md",
6009
6024
  "title": "SvGrid with React, Vue or Angular",
6010
6025
  "section": "help/web-components",
6011
- "markdown": "# SvGrid with React, Vue or Angular\n\nPick your framework and you have a running grid in about thirty seconds - no\nlocal setup, nothing to configure.\n\n## The 30-second path\n\n**1.** Install one package:\n\n<div data-docs-install=\"@svgrid/grid-wc\"></div>\n\n**2.** Import the component for your framework and render it:\n\n| Framework | Import | Tag |\n| --- | --- | --- |\n| React | `import { SvGrid } from '@svgrid/grid-wc/react'` | `<SvGrid data={rows} columns={cols} sortable filterable />` |\n| Vue | `import { SvGrid } from '@svgrid/grid-wc/vue'` | `<SvGrid :data=\"rows\" :columns=\"cols\" sortable filterable />` |\n| Angular | `import { SvGridComponent } from '@svgrid/grid-wc/angular'` | `<sv-grid [data]=\"rows\" [columns]=\"cols\" [sortable]=\"true\" />` |\n\nThat is the whole integration. All 115 properties and 27 events of the grid are\ntyped props on every one of them.\n\n**Or skip step 1** and open a working project right now:\n\n<div data-docs-sandbox=\"react:basic\" data-title=\"A first grid\"></div>\n<div data-docs-sandbox=\"vue:basic\" data-title=\"A first grid\"></div>\n<div data-docs-sandbox=\"angular:basic\" data-title=\"A first grid\"></div>\n\n## The examples\n\n<!-- BEGIN generated recipe table - packages/grid-wc/scripts/sync-example-docs.mjs -->\n\nThe same nine apps in each framework, so you can compare them\ndirectly - only the framework differs, never the data or the grid\nconfiguration. Every one is compiled in CI, so what you open is what works.\n\n| Recipe | What it shows | React | Vue | Angular |\n| --- | --- | --- | --- | --- |\n| A first grid | Rows, columns, and the two features almost every table wants | [→](./react.md#a-first-grid) | [→](./vue.md#a-first-grid) | [→](./angular.md#a-first-grid) |\n| Sorting and filtering | A filter row under the headers, multi-column sort, and the current sort read back into your own state | [→](./react.md#sorting-and-filtering) | [→](./vue.md#sorting-and-filtering) | [→](./angular.md#sorting-and-filtering) |\n| Editing and saving | Inline editing, with each committed edit arriving through `cellvaluechange` | [→](./react.md#editing-and-saving) | [→](./vue.md#editing-and-saving) | [→](./angular.md#editing-and-saving) |\n| Row selection | Checkboxes, with the selected rows handed straight to you - both the selection map and the rows themselves | [→](./react.md#row-selection) | [→](./vue.md#row-selection) | [→](./angular.md#row-selection) |\n| Grouping and totals | Group by one or two columns with an aggregate in the group row | [→](./react.md#grouping-and-totals) | [→](./vue.md#grouping-and-totals) | [→](./angular.md#grouping-and-totals) |\n| Pagination | Client-side paging | [→](./react.md#pagination) | [→](./vue.md#pagination) | [→](./angular.md#pagination) |\n| Server-side data | The grid renders the page you hand it and tells you when the user wants another | [→](./react.md#server-side-data) | [→](./vue.md#server-side-data) | [→](./angular.md#server-side-data) |\n| Theming | The `--sg-*` custom properties | [→](./react.md#theming) | [→](./vue.md#theming) | [→](./angular.md#theming) |\n| Excel export (Enterprise) | The paid pack from a non-Svelte host | [→](./react.md#excel-export-enterprise) | [→](./vue.md#excel-export-enterprise) | [→](./angular.md#excel-export-enterprise) |\n\n<!-- END generated recipe table -->\n## The chart element\n\nThe standalone chart ships as its own element, `<sv-chart>`, in the same\npackage: `import '@svgrid/grid-wc/chart'` registers it, and\n`@svgrid/grid-wc/react/chart`, `@svgrid/grid-wc/vue/chart` and the\n`SvChartComponent` export of the Angular package wrap it the same way the\ngrid is wrapped. Set `spec` as a property; the primitives (`zoomable`,\n`range-presets`, `legend=\"right\"`, `animate`, `drawable`) are attributes.\nThe [`<sv-chart>` reference](sv-chart.md) lists all of it, and the\n[charts guide](../charts.md) says what goes in a spec. It carries none of the\ngrid, so a page that only charts loads the chart.\n\n## What each wrapper is actually for\n\nThe grid is a custom element underneath, and a custom element is awkward in each\nframework in a different way. Each wrapper removes exactly that awkwardness:\n\n- **React** - React 18 and earlier stringify object props onto **attributes**,\n so `columns={cols}` silently becomes `\"[object Object]\"` and the grid renders\n empty with no error. The wrapper assigns them as properties on every version.\n- **Vue** - removes the `isCustomElement` build config and the `.prop` modifier\n that every object binding would otherwise need.\n- **Angular** - removes `CUSTOM_ELEMENTS_SCHEMA` from every component that shows\n a grid, and gives you typed `@Input` / `@Output` instead of an untyped\n element. Its selector is the element's own tag, so there is no extra wrapper\n element in your DOM.\n\nAll three also handle two ordering problems you would otherwise meet yourself:\nthe element renders **before** a framework assigns properties in an effect, and\n`apiready` fires once during that first mount - before React can bind a listener\nat all.\n\n## The imperative api\n\nEvery wrapper exposes the grid api once it is ready:\n\n- **React** - a ref: `grid.current?.api`\n- **Vue** - a template ref: `grid.value?.api`\n- **Angular** - the component instance: `grid.api`\n\n## Not using a framework?\n\n`<sv-grid>` works in plain HTML with one `<script>` tag - see\n[Quick start](./quick-start.md).\n\n## See also\n\n- [`<sv-grid>` reference](./sv-grid.md) - every property, attribute and event.\n- [Shadow DOM](./shadow-dom.md) - for pages whose CSS you do not control.\n- [TypeScript](./typescript.md) - typing the raw element, if you use it directly.\n- [Limitations](./limitations.md) - what cannot cross the boundary.\n"
6026
+ "markdown": "# SvGrid with React, Vue or Angular\n\nPick your framework and you have a running grid in about thirty seconds - no\nlocal setup, nothing to configure.\n\n## The 30-second path\n\n**1.** Install one package:\n\n<div data-docs-install=\"@svgrid/grid-wc\"></div>\n\n**2.** Import the component for your framework and render it:\n\n| Framework | Import | Tag |\n| --- | --- | --- |\n| React | `import { SvGrid } from '@svgrid/grid-wc/react'` | `<SvGrid data={rows} columns={cols} sortable filterable />` |\n| Vue | `import { SvGrid } from '@svgrid/grid-wc/vue'` | `<SvGrid :data=\"rows\" :columns=\"cols\" sortable filterable />` |\n| Angular | `import { SvGridComponent } from '@svgrid/grid-wc/angular'` | `<sv-grid [data]=\"rows\" [columns]=\"cols\" [sortable]=\"true\" />` |\n\nThat is the whole integration. All 116 properties and 27 events of the grid are\ntyped props on every one of them.\n\n**Or skip step 1** and open a working project right now:\n\n<div data-docs-sandbox=\"react:basic\" data-title=\"A first grid\"></div>\n<div data-docs-sandbox=\"vue:basic\" data-title=\"A first grid\"></div>\n<div data-docs-sandbox=\"angular:basic\" data-title=\"A first grid\"></div>\n\n## The examples\n\n<!-- BEGIN generated recipe table - packages/grid-wc/scripts/sync-example-docs.mjs -->\n\nThe same nine apps in each framework, so you can compare them\ndirectly - only the framework differs, never the data or the grid\nconfiguration. Every one is compiled in CI, so what you open is what works.\n\n| Recipe | What it shows | React | Vue | Angular |\n| --- | --- | --- | --- | --- |\n| A first grid | Rows, columns, and the two features almost every table wants | [→](./react.md#a-first-grid) | [→](./vue.md#a-first-grid) | [→](./angular.md#a-first-grid) |\n| Sorting and filtering | A filter row under the headers, multi-column sort, and the current sort read back into your own state | [→](./react.md#sorting-and-filtering) | [→](./vue.md#sorting-and-filtering) | [→](./angular.md#sorting-and-filtering) |\n| Editing and saving | Inline editing, with each committed edit arriving through `cellvaluechange` | [→](./react.md#editing-and-saving) | [→](./vue.md#editing-and-saving) | [→](./angular.md#editing-and-saving) |\n| Row selection | Checkboxes, with the selected rows handed straight to you - both the selection map and the rows themselves | [→](./react.md#row-selection) | [→](./vue.md#row-selection) | [→](./angular.md#row-selection) |\n| Grouping and totals | Group by one or two columns with an aggregate in the group row | [→](./react.md#grouping-and-totals) | [→](./vue.md#grouping-and-totals) | [→](./angular.md#grouping-and-totals) |\n| Pagination | Client-side paging | [→](./react.md#pagination) | [→](./vue.md#pagination) | [→](./angular.md#pagination) |\n| Server-side data | The grid renders the page you hand it and tells you when the user wants another | [→](./react.md#server-side-data) | [→](./vue.md#server-side-data) | [→](./angular.md#server-side-data) |\n| Theming | The `--sg-*` custom properties | [→](./react.md#theming) | [→](./vue.md#theming) | [→](./angular.md#theming) |\n| Excel export (Enterprise) | The paid pack from a non-Svelte host | [→](./react.md#excel-export-enterprise) | [→](./vue.md#excel-export-enterprise) | [→](./angular.md#excel-export-enterprise) |\n\n<!-- END generated recipe table -->\n## The chart element\n\nThe standalone chart ships as its own element, `<sv-chart>`, in the same\npackage: `import '@svgrid/grid-wc/chart'` registers it, and\n`@svgrid/grid-wc/react/chart`, `@svgrid/grid-wc/vue/chart` and the\n`SvChartComponent` export of the Angular package wrap it the same way the\ngrid is wrapped. Set `spec` as a property; the primitives (`zoomable`,\n`range-presets`, `legend=\"right\"`, `animate`, `drawable`) are attributes.\nThe [`<sv-chart>` reference](sv-chart.md) lists all of it, and the\n[charts guide](../charts.md) says what goes in a spec. It carries none of the\ngrid, so a page that only charts loads the chart.\n\n## What each wrapper is actually for\n\nThe grid is a custom element underneath, and a custom element is awkward in each\nframework in a different way. Each wrapper removes exactly that awkwardness:\n\n- **React** - React 18 and earlier stringify object props onto **attributes**,\n so `columns={cols}` silently becomes `\"[object Object]\"` and the grid renders\n empty with no error. The wrapper assigns them as properties on every version.\n- **Vue** - removes the `isCustomElement` build config and the `.prop` modifier\n that every object binding would otherwise need.\n- **Angular** - removes `CUSTOM_ELEMENTS_SCHEMA` from every component that shows\n a grid, and gives you typed `@Input` / `@Output` instead of an untyped\n element. Its selector is the element's own tag, so there is no extra wrapper\n element in your DOM.\n\nAll three also handle two ordering problems you would otherwise meet yourself:\nthe element renders **before** a framework assigns properties in an effect, and\n`apiready` fires once during that first mount - before React can bind a listener\nat all.\n\n## The imperative api\n\nEvery wrapper exposes the grid api once it is ready:\n\n- **React** - a ref: `grid.current?.api`\n- **Vue** - a template ref: `grid.value?.api`\n- **Angular** - the component instance: `grid.api`\n\n## Not using a framework?\n\n`<sv-grid>` works in plain HTML with one `<script>` tag - see\n[Quick start](./quick-start.md).\n\n## See also\n\n- [`<sv-grid>` reference](./sv-grid.md) - every property, attribute and event.\n- [Shadow DOM](./shadow-dom.md) - for pages whose CSS you do not control.\n- [TypeScript](./typescript.md) - typing the raw element, if you use it directly.\n- [Limitations](./limitations.md) - what cannot cross the boundary.\n"
6012
6027
  },
6013
6028
  {
6014
6029
  "slug": "help/web-components/limitations",
@@ -6029,14 +6044,14 @@ export const docs = [
6029
6044
  "path": "docs/help/web-components/react.md",
6030
6045
  "title": "SvGrid in React",
6031
6046
  "section": "help/web-components",
6032
- "markdown": "# SvGrid in React\n\n```bash\nnpm install @svgrid/grid-wc\n```\n\n```tsx\nimport { SvGrid } from '@svgrid/grid-wc/react'\n\nexport default function Screen() {\n return (\n <div style={{ height: 480 }}>\n <SvGrid\n data={rows}\n columns={columns}\n sortable\n filterable\n editable\n onCellValueChange={(e) => save(e)}\n />\n </div>\n )\n}\n```\n\nThat is the whole integration. The component is generated from `<SvGrid>`'s\nown types, so all 115 properties and 27 events are typed props, and it works the\nsame on React 17, 18 and 19.\n\n## Why the wrapper rather than the raw element\n\n- **React 18 and earlier stringify object props onto attributes.**\n `columns={cols}` becomes the attribute `\"[object Object]\"` and the grid\n renders empty, with no error. React 19 assigns them as properties; the wrapper\n does so on every version.\n- **The element renders before your props arrive.** Both React and Angular\n create the element first and assign properties in an effect, so the grid\n mounts once with nothing. The wrapper handles that ordering.\n- **`apiready` fires once, during that first mount** - before React can bind a\n listener, so a raw `addEventListener` misses it entirely. The wrapper replays\n it.\n- Typed props and handlers, instead of `el.columns = ...` on an `HTMLElement`.\n\n## Shadow DOM\n\n`shadow` picks the style-isolated element. Same props, same events; the extra\nbundle loads only when you ask for it.\n\n```tsx\n<SvGrid shadow data={rows} columns={columns} />\n```\n\n## The imperative api\n\n```tsx\nimport { useRef } from 'react'\nimport { SvGrid, type SvGridHandle } from '@svgrid/grid-wc/react'\n\nexport function Screen() {\n const grid = useRef<SvGridHandle>(null)\n\n const exportCsv = () => {\n // `api` is the grid api; `element` is the host custom element.\n ;(grid.current?.api as { exportCsv(): void } | undefined)?.exportCsv()\n }\n\n return <SvGrid ref={grid} data={rows} columns={columns} />\n}\n```\n\n<!-- BEGIN generated examples - packages/grid-wc/scripts/sync-example-docs.mjs -->\n\n## Examples\n\nNine complete apps. Each one is **running on this page** - sort it,\nfilter it, edit a cell - above the code that produces it. **Open in StackBlitz**\nthen boots the whole project, editable, with no local install and nothing to\nconfigure, and every one is compiled in this repository's CI, so what you open\nis what works.\n\nThey all share the same typed `data.ts`, so the only thing that changes\nbetween recipes is the grid.\n\nA note on the previews: the grid you can touch is driven by a Svelte host\nrather than a React one. It is not a recording or a\nlookalike - it renders through the same component `<sv-grid>` renders, with\nthe same data and the same props as the listing below it, so the only thing\nthat differs from the React app is who sets those props. Click **Open in\nStackBlitz** to run the real React one.\n\n### A first grid\n\nRows, columns, and the two features almost every table wants.\n\n<div data-docs-mirror=\"basic\" data-height=\"420\"></div>\n\n<div data-docs-sandbox=\"react:basic\" data-title=\"A first grid\"></div>\n\n```tsx {nocheck}\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns } from '../data'\n\n/**\n * The smallest useful grid: rows, columns, and the two features almost every\n * table wants. `data` and `columns` are arrays, so they cross as properties -\n * the wrapper handles that on every React version.\n */\nexport default function App() {\n return (\n <div style={{ height: '100%', padding: 16 }}>\n <SvGrid data={people} columns={columns} sortable filterable />\n </div>\n )\n}\n```\n\n### Sorting and filtering\n\nA filter row under the headers, multi-column sort, and the current sort read back into your own state.\n\n<div data-docs-mirror=\"sorting-filtering\" data-height=\"460\"></div>\n\n<div data-docs-sandbox=\"react:sorting-filtering\" data-title=\"Sorting and filtering\"></div>\n\n```tsx {nocheck}\nimport { useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns } from '../data'\n\n/**\n * A filter row under the headers, multi-column sort, and the current sort read\n * back into React state. `onSortingChange` hands you the same array the grid\n * holds, so it can drive your own UI.\n */\nexport default function App() {\n const [sorting, setSorting] = useState<Array<{ id: string; desc: boolean }>>([])\n\n return (\n <div style={{ height: '100%', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>\n <p style={{ margin: 0, font: '13px system-ui', color: '#64748b' }}>\n Sorted by:{' '}\n {sorting.length === 0\n ? 'nothing yet - click a header, then shift-click a second one'\n : sorting.map((s) => `${s.id} ${s.desc ? 'desc' : 'asc'}`).join(', ')}\n </p>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid\n data={people}\n columns={columns}\n sortable\n filterable\n showFilterRow\n onSortingChange={setSorting}\n />\n </div>\n </div>\n )\n}\n```\n\n### Editing and saving\n\nInline editing, with each committed edit arriving through `cellvaluechange`. Swap the local update for your save call.\n\n<div data-docs-mirror=\"editing\" data-height=\"460\"></div>\n\n<div data-docs-sandbox=\"react:editing\" data-title=\"Editing and saving\"></div>\n\n```tsx {nocheck}\nimport { useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns, type Person } from '../data'\n\n/**\n * Inline editing, with the change written back into React state.\n *\n * `cellvaluechange` fires once per committed edit and its detail is typed, so\n * `rowIndex`, `columnId` and `newValue` come through without a cast. Replace\n * the setState with your own save call.\n */\nexport default function App() {\n const [rows, setRows] = useState<Person[]>(people)\n const [log, setLog] = useState<string[]>([])\n\n return (\n <div style={{ height: '100%', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>\n <p style={{ margin: 0, font: '13px system-ui', color: '#64748b' }}>\n Double-click a Name or Amount cell. Last edits: {log.slice(-3).join(' · ') || 'none yet'}\n </p>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid\n data={rows}\n columns={columns.map((c) =>\n c.id === 'name' || c.id === 'amount' ? { ...c, editable: true } : c,\n )}\n sortable\n filterable\n editable\n onCellValueChange={(e) => {\n setRows((prev) =>\n prev.map((row, i) => (i === e.rowIndex ? { ...row, [e.columnId]: e.newValue } : row)),\n )\n setLog((prev) => [...prev, `${e.columnId} = ${String(e.newValue)}`])\n }}\n />\n </div>\n </div>\n )\n}\n```\n\n### Row selection\n\nCheckboxes, with the selected rows handed straight to you - both the selection map and the rows themselves.\n\n<div data-docs-mirror=\"selection\" data-height=\"460\"></div>\n\n<div data-docs-sandbox=\"react:selection\" data-title=\"Row selection\"></div>\n\n```tsx {nocheck}\nimport { useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns, type Person } from '../data'\n\n/**\n * Row checkboxes, with the selected rows handed to React so you can act on\n * them. `rowselectionchange` carries both the selection map and the rows\n * themselves, so you rarely need to look anything up.\n */\nexport default function App() {\n const [selected, setSelected] = useState<Person[]>([])\n const total = selected.reduce((sum, r) => sum + r.amount, 0)\n\n return (\n <div style={{ height: '100%', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>\n <div style={{ font: '13px system-ui', color: '#64748b' }}>\n {selected.length === 0\n ? 'Tick some rows to total them'\n : `${selected.length} selected · ${total.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })}`}\n </div>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid\n data={people}\n columns={columns}\n sortable\n filterable\n showRowSelection\n onRowSelectionChange={(e) => setSelected(e.rows as Person[])}\n />\n </div>\n </div>\n )\n}\n```\n\n### Grouping and totals\n\nGroup by one or two columns with an aggregate in the group row. `groupBy` is an array, so it is one of the props that can only be a property.\n\n<div data-docs-mirror=\"grouping\" data-height=\"470\"></div>\n\n<div data-docs-sandbox=\"react:grouping\" data-title=\"Grouping and totals\"></div>\n\n```tsx {nocheck}\nimport { useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns } from '../data'\n\n/**\n * Grouping by one or two columns, with an aggregate in the group row.\n *\n * `groupBy` is an array, so it is one of the props that can only be a\n * property - which is the whole reason the React wrapper exists.\n */\nexport default function App() {\n const [groupBy, setGroupBy] = useState<string[]>(['team'])\n\n return (\n <div style={{ height: '100%', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>\n <div style={{ display: 'flex', gap: 8, font: '13px system-ui' }}>\n {[['team'], ['country'], ['team', 'country'], []].map((g) => (\n <button\n key={g.join('+') || 'none'}\n onClick={() => setGroupBy(g)}\n style={{\n padding: '4px 10px',\n borderRadius: 6,\n border: '1px solid #cbd5e1',\n background: groupBy.join() === g.join() ? '#e2e8f0' : '#fff',\n cursor: 'pointer',\n }}\n >\n {g.join(' + ') || 'No grouping'}\n </button>\n ))}\n </div>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid\n data={people}\n columns={columns.map((c) => (c.id === 'amount' ? { ...c, aggregate: 'sum' } : c))}\n sortable\n filterable\n groupable\n groupBy={groupBy}\n groupFooters\n />\n </div>\n </div>\n )\n}\n```\n\n### Pagination\n\nClient-side paging. `pageSize` is the INITIAL page size, read once at mount.\n\n<div data-docs-mirror=\"pagination\" data-height=\"440\"></div>\n\n<div data-docs-sandbox=\"react:pagination\" data-title=\"Pagination\"></div>\n\n```tsx {nocheck}\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns } from '../data'\n\n/**\n * Client-side paging. `pageSize` is read once at mount - it is the INITIAL\n * page size - so change pages through the footer or the grid api rather than\n * by reassigning the prop.\n */\nexport default function App() {\n return (\n <div style={{ height: '100%', padding: 16 }}>\n <SvGrid\n data={people}\n columns={columns}\n sortable\n filterable\n pageable\n pageSize={25}\n pageSizeOptions={[10, 25, 50, 100]}\n paginationPosition=\"bottom\"\n />\n </div>\n )\n}\n```\n\n### Server-side data\n\nThe grid renders the page you hand it and tells you when the user wants another. `externalSort` and `externalPagination` stop it doing the work locally; `rowCount` is how it knows how many pages exist.\n\n<div data-docs-mirror=\"server-data\" data-height=\"440\"></div>\n\n<div data-docs-sandbox=\"react:server-data\" data-title=\"Server-side data\"></div>\n\n```tsx {nocheck}\nimport { useEffect, useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns, type Person } from '../data'\n\ntype Query = { page: number; sort: Array<{ id: string; desc: boolean }> }\n\n/**\n * Server-side data: the grid renders whatever page you hand it and tells you\n * when the user wants a different one.\n *\n * `externalSort` and `externalPagination` stop the grid doing the work\n * locally; `rowCount` is how it knows how many pages exist. `fetchPage` here\n * stands in for your API call.\n */\nconst PAGE_SIZE = 25\n\nfunction fetchPage({ page, sort }: Query): Promise<{ rows: Person[]; total: number }> {\n // Pretend this is a network call.\n const sorted = [...people].sort((a, b) => {\n for (const s of sort) {\n const av = a[s.id as keyof Person]\n const bv = b[s.id as keyof Person]\n if (av === bv) continue\n return (av > bv ? 1 : -1) * (s.desc ? -1 : 1)\n }\n return 0\n })\n const start = page * PAGE_SIZE\n return new Promise((resolve) =>\n setTimeout(() => resolve({ rows: sorted.slice(start, start + PAGE_SIZE), total: sorted.length }), 250),\n )\n}\n\nexport default function App() {\n const [query, setQuery] = useState<Query>({ page: 0, sort: [] })\n const [rows, setRows] = useState<Person[]>([])\n const [total, setTotal] = useState(0)\n const [loading, setLoading] = useState(true)\n\n useEffect(() => {\n let cancelled = false\n setLoading(true)\n fetchPage(query).then((res) => {\n if (cancelled) return\n setRows(res.rows)\n setTotal(res.total)\n setLoading(false)\n })\n return () => {\n cancelled = true\n }\n }, [query])\n\n return (\n <div style={{ height: '100%', padding: 16 }}>\n <SvGrid\n data={rows}\n columns={columns}\n loading={loading}\n sortable\n externalSort\n externalPagination\n pageable\n pageSize={PAGE_SIZE}\n rowCount={total}\n pageIndex={query.page}\n onSortingChange={(sort) => setQuery((q) => ({ ...q, sort, page: 0 }))}\n onPaginationChange={(p) => setQuery((q) => ({ ...q, page: (p as { pageIndex: number }).pageIndex }))}\n />\n </div>\n )\n}\n```\n\n### Theming\n\nThe `--sg-*` custom properties. Ordinary CSS custom properties, so they cascade from any ancestor - which is why they also reach inside `<sv-grid-shadow>`.\n\n<div data-docs-mirror=\"theming\" data-height=\"470\"></div>\n\n<div data-docs-sandbox=\"react:theming\" data-title=\"Theming\"></div>\n\n```tsx {nocheck}\nimport { useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns } from '../data'\n\n/**\n * Theming through `--sg-*` custom properties.\n *\n * They are ordinary CSS custom properties, so they cascade: set them on any\n * ancestor and the grid inherits. That is also why they reach into\n * `<sv-grid-shadow>` - inheritance crosses a shadow boundary.\n */\nconst THEMES = {\n Light: {},\n Dark: {\n '--sg-bg': '#0b1220',\n '--sg-fg': '#e2e8f0',\n '--sg-border': '#1e293b',\n '--sg-header-bg': '#111a2e',\n '--sg-row-hover': '#111a2e',\n },\n Warm: {\n '--sg-bg': '#fffaf5',\n '--sg-fg': '#42302a',\n '--sg-border': '#f0dcc9',\n '--sg-header-bg': '#fdf1e4',\n '--sg-accent': '#c2410c',\n },\n} satisfies Record<string, Record<string, string>>\n\nexport default function App() {\n const [theme, setTheme] = useState<keyof typeof THEMES>('Light')\n\n return (\n <div\n style={{\n height: '100%',\n padding: 16,\n display: 'flex',\n flexDirection: 'column',\n gap: 12,\n ...THEMES[theme],\n }}\n >\n <div style={{ display: 'flex', gap: 8 }}>\n {(Object.keys(THEMES) as Array<keyof typeof THEMES>).map((name) => (\n <button\n key={name}\n onClick={() => setTheme(name)}\n style={{\n padding: '4px 10px',\n borderRadius: 6,\n border: '1px solid #cbd5e1',\n background: theme === name ? '#e2e8f0' : '#fff',\n cursor: 'pointer',\n }}\n >\n {name}\n </button>\n ))}\n </div>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid data={people} columns={columns} sortable filterable zebraRows />\n </div>\n </div>\n )\n}\n```\n\n### Excel export (Enterprise)\n\nThe paid pack from a non-Svelte host. `@svgrid/enterprise/export` is plain JavaScript, so it needs no Svelte in your build - the same goes for `/import`, `/print`, `/pivot` and `/license`. See [Enterprise features](./enterprise.md) for what those subpaths cover and what needs a Svelte-aware bundler.\n\n<div data-docs-mirror=\"enterprise\" data-height=\"460\"></div>\n\n<div data-docs-sandbox=\"react:enterprise\" data-title=\"Excel export (Enterprise)\"></div>\n\n```tsx {nocheck}\nimport { useRef, useState } from 'react'\nimport { SvGrid, type SvGridHandle } from '@svgrid/grid-wc/react'\nimport { exportGrid } from '@svgrid/enterprise/export'\nimport { people, columns } from '../data'\n\n/**\n * Excel export from a React host.\n *\n * `@svgrid/enterprise/export` is plain JavaScript - no Svelte in your build -\n * so this works in any bundler. The same is true of `/import`, `/print`,\n * `/pivot` and `/license`. The Kanban and scheduler views are the exception:\n * they are Svelte components, so they need a Svelte-aware bundler.\n *\n * The api arrives on the handle once `apiready` has fired. It is typed\n * `unknown` there because the wrapper does not depend on the grid's types, so\n * the enterprise call needs a cast.\n */\nexport default function App() {\n const grid = useRef<SvGridHandle>(null)\n const [ready, setReady] = useState(false)\n const [busy, setBusy] = useState(false)\n\n async function exportXlsx() {\n if (!grid.current?.api) return\n setBusy(true)\n try {\n await exportGrid(grid.current.api as Parameters<typeof exportGrid>[0], {\n format: 'xlsx',\n filename: 'people',\n })\n } finally {\n setBusy(false)\n }\n }\n\n return (\n <div style={{ height: '100%', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>\n <button type=\"button\" onClick={exportXlsx} disabled={!ready || busy}>\n {busy ? 'Building…' : 'Export to Excel'}\n </button>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid\n ref={grid}\n data={people}\n columns={columns}\n sortable\n filterable\n onApiReady={() => setReady(true)}\n />\n </div>\n </div>\n )\n}\n```\n\n<!-- END generated examples -->\n\n## Rendering into cells\n\nYou cannot put a React component inside a cell: cell rendering is a Svelte\ncompile-time feature, and there is nothing to hand across the boundary. Column\n`format` options, `fieldFn` and HTML-string renderers cover badges, links and\nformatted values.\n\nColumn `format` and HTML-string renderers, which is what to reach for instead:\n\n<div data-docs-demo=\"10-custom-cells-and-themes\" data-height=\"480\"></div>\n\nIf a grid's cells must render your own components, use the Svelte component\ndirectly. See [limitations](./limitations.md).\n\n## Why it lives in `@svgrid/grid-wc`\n\nRather than a separate `@svgrid/react` package: one version to keep in step\nwith the element instead of two, and `react` is an OPTIONAL peer dependency, so\na plain-HTML consumer never installs React. The wrapper is a subpath import and\nabout 1.4 KB - it reuses the one element bundle rather than shipping a second\ncopy of the grid.\n\n## The raw element\n\n`<SvGrid>` wraps `<sv-grid>`, and nothing stops you using the element\ndirectly - it is the same grid, and the wrapper is a thin layer over it. You\nwould be taking on the property assignment, the event binding and the two\nordering problems the wrapper handles, so do it only if you have a reason to.\n\n[Quick start](./quick-start.md) covers the element, and the\n[reference](./sv-grid.md) lists every property, attribute and event.\n\n## See also\n\n- [All frameworks](./frameworks.md) - the same examples in Vue and Angular.\n- [`<sv-grid>` reference](./sv-grid.md) - every property, attribute and event.\n- [TypeScript](./typescript.md) - typing the element, if you use it directly.\n- [Limitations](./limitations.md) - what cannot cross the boundary.\n"
6047
+ "markdown": "# SvGrid in React\n\n```bash\nnpm install @svgrid/grid-wc\n```\n\n```tsx\nimport { SvGrid } from '@svgrid/grid-wc/react'\n\nexport default function Screen() {\n return (\n <div style={{ height: 480 }}>\n <SvGrid\n data={rows}\n columns={columns}\n sortable\n filterable\n editable\n onCellValueChange={(e) => save(e)}\n />\n </div>\n )\n}\n```\n\nThat is the whole integration. The component is generated from `<SvGrid>`'s\nown types, so all 116 properties and 27 events are typed props, and it works the\nsame on React 17, 18 and 19.\n\n## Why the wrapper rather than the raw element\n\n- **React 18 and earlier stringify object props onto attributes.**\n `columns={cols}` becomes the attribute `\"[object Object]\"` and the grid\n renders empty, with no error. React 19 assigns them as properties; the wrapper\n does so on every version.\n- **The element renders before your props arrive.** Both React and Angular\n create the element first and assign properties in an effect, so the grid\n mounts once with nothing. The wrapper handles that ordering.\n- **`apiready` fires once, during that first mount** - before React can bind a\n listener, so a raw `addEventListener` misses it entirely. The wrapper replays\n it.\n- Typed props and handlers, instead of `el.columns = ...` on an `HTMLElement`.\n\n## Shadow DOM\n\n`shadow` picks the style-isolated element. Same props, same events; the extra\nbundle loads only when you ask for it.\n\n```tsx\n<SvGrid shadow data={rows} columns={columns} />\n```\n\n## The imperative api\n\n```tsx\nimport { useRef } from 'react'\nimport { SvGrid, type SvGridHandle } from '@svgrid/grid-wc/react'\n\nexport function Screen() {\n const grid = useRef<SvGridHandle>(null)\n\n const exportCsv = () => {\n // `api` is the grid api; `element` is the host custom element.\n ;(grid.current?.api as { exportCsv(): void } | undefined)?.exportCsv()\n }\n\n return <SvGrid ref={grid} data={rows} columns={columns} />\n}\n```\n\n<!-- BEGIN generated examples - packages/grid-wc/scripts/sync-example-docs.mjs -->\n\n## Examples\n\nNine complete apps. Each one is **running on this page** - sort it,\nfilter it, edit a cell - above the code that produces it. **Open in StackBlitz**\nthen boots the whole project, editable, with no local install and nothing to\nconfigure, and every one is compiled in this repository's CI, so what you open\nis what works.\n\nThey all share the same typed `data.ts`, so the only thing that changes\nbetween recipes is the grid.\n\nA note on the previews: the grid you can touch is driven by a Svelte host\nrather than a React one. It is not a recording or a\nlookalike - it renders through the same component `<sv-grid>` renders, with\nthe same data and the same props as the listing below it, so the only thing\nthat differs from the React app is who sets those props. Click **Open in\nStackBlitz** to run the real React one.\n\n### A first grid\n\nRows, columns, and the two features almost every table wants.\n\n<div data-docs-mirror=\"basic\" data-height=\"420\"></div>\n\n<div data-docs-sandbox=\"react:basic\" data-title=\"A first grid\"></div>\n\n```tsx {nocheck}\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns } from '../data'\n\n/**\n * The smallest useful grid: rows, columns, and the two features almost every\n * table wants. `data` and `columns` are arrays, so they cross as properties -\n * the wrapper handles that on every React version.\n */\nexport default function App() {\n return (\n <div style={{ height: '100%', padding: 16 }}>\n <SvGrid data={people} columns={columns} sortable filterable />\n </div>\n )\n}\n```\n\n### Sorting and filtering\n\nA filter row under the headers, multi-column sort, and the current sort read back into your own state.\n\n<div data-docs-mirror=\"sorting-filtering\" data-height=\"460\"></div>\n\n<div data-docs-sandbox=\"react:sorting-filtering\" data-title=\"Sorting and filtering\"></div>\n\n```tsx {nocheck}\nimport { useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns } from '../data'\n\n/**\n * A filter row under the headers, multi-column sort, and the current sort read\n * back into React state. `onSortingChange` hands you the same array the grid\n * holds, so it can drive your own UI.\n */\nexport default function App() {\n const [sorting, setSorting] = useState<Array<{ id: string; desc: boolean }>>([])\n\n return (\n <div style={{ height: '100%', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>\n <p style={{ margin: 0, font: '13px system-ui', color: '#64748b' }}>\n Sorted by:{' '}\n {sorting.length === 0\n ? 'nothing yet - click a header, then shift-click a second one'\n : sorting.map((s) => `${s.id} ${s.desc ? 'desc' : 'asc'}`).join(', ')}\n </p>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid\n data={people}\n columns={columns}\n sortable\n filterable\n showFilterRow\n onSortingChange={setSorting}\n />\n </div>\n </div>\n )\n}\n```\n\n### Editing and saving\n\nInline editing, with each committed edit arriving through `cellvaluechange`. Swap the local update for your save call.\n\n<div data-docs-mirror=\"editing\" data-height=\"460\"></div>\n\n<div data-docs-sandbox=\"react:editing\" data-title=\"Editing and saving\"></div>\n\n```tsx {nocheck}\nimport { useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns, type Person } from '../data'\n\n/**\n * Inline editing, with the change written back into React state.\n *\n * `cellvaluechange` fires once per committed edit and its detail is typed, so\n * `rowIndex`, `columnId` and `newValue` come through without a cast. Replace\n * the setState with your own save call.\n */\nexport default function App() {\n const [rows, setRows] = useState<Person[]>(people)\n const [log, setLog] = useState<string[]>([])\n\n return (\n <div style={{ height: '100%', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>\n <p style={{ margin: 0, font: '13px system-ui', color: '#64748b' }}>\n Double-click a Name or Amount cell. Last edits: {log.slice(-3).join(' · ') || 'none yet'}\n </p>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid\n data={rows}\n columns={columns.map((c) =>\n c.id === 'name' || c.id === 'amount' ? { ...c, editable: true } : c,\n )}\n sortable\n filterable\n editable\n onCellValueChange={(e) => {\n setRows((prev) =>\n prev.map((row, i) => (i === e.rowIndex ? { ...row, [e.columnId]: e.newValue } : row)),\n )\n setLog((prev) => [...prev, `${e.columnId} = ${String(e.newValue)}`])\n }}\n />\n </div>\n </div>\n )\n}\n```\n\n### Row selection\n\nCheckboxes, with the selected rows handed straight to you - both the selection map and the rows themselves.\n\n<div data-docs-mirror=\"selection\" data-height=\"460\"></div>\n\n<div data-docs-sandbox=\"react:selection\" data-title=\"Row selection\"></div>\n\n```tsx {nocheck}\nimport { useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns, type Person } from '../data'\n\n/**\n * Row checkboxes, with the selected rows handed to React so you can act on\n * them. `rowselectionchange` carries both the selection map and the rows\n * themselves, so you rarely need to look anything up.\n */\nexport default function App() {\n const [selected, setSelected] = useState<Person[]>([])\n const total = selected.reduce((sum, r) => sum + r.amount, 0)\n\n return (\n <div style={{ height: '100%', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>\n <div style={{ font: '13px system-ui', color: '#64748b' }}>\n {selected.length === 0\n ? 'Tick some rows to total them'\n : `${selected.length} selected · ${total.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })}`}\n </div>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid\n data={people}\n columns={columns}\n sortable\n filterable\n showRowSelection\n onRowSelectionChange={(e) => setSelected(e.rows as Person[])}\n />\n </div>\n </div>\n )\n}\n```\n\n### Grouping and totals\n\nGroup by one or two columns with an aggregate in the group row. `groupBy` is an array, so it is one of the props that can only be a property.\n\n<div data-docs-mirror=\"grouping\" data-height=\"470\"></div>\n\n<div data-docs-sandbox=\"react:grouping\" data-title=\"Grouping and totals\"></div>\n\n```tsx {nocheck}\nimport { useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns } from '../data'\n\n/**\n * Grouping by one or two columns, with an aggregate in the group row.\n *\n * `groupBy` is an array, so it is one of the props that can only be a\n * property - which is the whole reason the React wrapper exists.\n */\nexport default function App() {\n const [groupBy, setGroupBy] = useState<string[]>(['team'])\n\n return (\n <div style={{ height: '100%', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>\n <div style={{ display: 'flex', gap: 8, font: '13px system-ui' }}>\n {[['team'], ['country'], ['team', 'country'], []].map((g) => (\n <button\n key={g.join('+') || 'none'}\n onClick={() => setGroupBy(g)}\n style={{\n padding: '4px 10px',\n borderRadius: 6,\n border: '1px solid #cbd5e1',\n background: groupBy.join() === g.join() ? '#e2e8f0' : '#fff',\n cursor: 'pointer',\n }}\n >\n {g.join(' + ') || 'No grouping'}\n </button>\n ))}\n </div>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid\n data={people}\n columns={columns.map((c) => (c.id === 'amount' ? { ...c, aggregate: 'sum' } : c))}\n sortable\n filterable\n groupable\n groupBy={groupBy}\n groupFooters\n />\n </div>\n </div>\n )\n}\n```\n\n### Pagination\n\nClient-side paging. `pageSize` is the INITIAL page size, read once at mount.\n\n<div data-docs-mirror=\"pagination\" data-height=\"440\"></div>\n\n<div data-docs-sandbox=\"react:pagination\" data-title=\"Pagination\"></div>\n\n```tsx {nocheck}\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns } from '../data'\n\n/**\n * Client-side paging. `pageSize` is read once at mount - it is the INITIAL\n * page size - so change pages through the footer or the grid api rather than\n * by reassigning the prop.\n */\nexport default function App() {\n return (\n <div style={{ height: '100%', padding: 16 }}>\n <SvGrid\n data={people}\n columns={columns}\n sortable\n filterable\n pageable\n pageSize={25}\n pageSizeOptions={[10, 25, 50, 100]}\n paginationPosition=\"bottom\"\n />\n </div>\n )\n}\n```\n\n### Server-side data\n\nThe grid renders the page you hand it and tells you when the user wants another. `externalSort` and `externalPagination` stop it doing the work locally; `rowCount` is how it knows how many pages exist.\n\n<div data-docs-mirror=\"server-data\" data-height=\"440\"></div>\n\n<div data-docs-sandbox=\"react:server-data\" data-title=\"Server-side data\"></div>\n\n```tsx {nocheck}\nimport { useEffect, useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns, type Person } from '../data'\n\ntype Query = { page: number; sort: Array<{ id: string; desc: boolean }> }\n\n/**\n * Server-side data: the grid renders whatever page you hand it and tells you\n * when the user wants a different one.\n *\n * `externalSort` and `externalPagination` stop the grid doing the work\n * locally; `rowCount` is how it knows how many pages exist. `fetchPage` here\n * stands in for your API call.\n */\nconst PAGE_SIZE = 25\n\nfunction fetchPage({ page, sort }: Query): Promise<{ rows: Person[]; total: number }> {\n // Pretend this is a network call.\n const sorted = [...people].sort((a, b) => {\n for (const s of sort) {\n const av = a[s.id as keyof Person]\n const bv = b[s.id as keyof Person]\n if (av === bv) continue\n return (av > bv ? 1 : -1) * (s.desc ? -1 : 1)\n }\n return 0\n })\n const start = page * PAGE_SIZE\n return new Promise((resolve) =>\n setTimeout(() => resolve({ rows: sorted.slice(start, start + PAGE_SIZE), total: sorted.length }), 250),\n )\n}\n\nexport default function App() {\n const [query, setQuery] = useState<Query>({ page: 0, sort: [] })\n const [rows, setRows] = useState<Person[]>([])\n const [total, setTotal] = useState(0)\n const [loading, setLoading] = useState(true)\n\n useEffect(() => {\n let cancelled = false\n setLoading(true)\n fetchPage(query).then((res) => {\n if (cancelled) return\n setRows(res.rows)\n setTotal(res.total)\n setLoading(false)\n })\n return () => {\n cancelled = true\n }\n }, [query])\n\n return (\n <div style={{ height: '100%', padding: 16 }}>\n <SvGrid\n data={rows}\n columns={columns}\n loading={loading}\n sortable\n externalSort\n externalPagination\n pageable\n pageSize={PAGE_SIZE}\n rowCount={total}\n pageIndex={query.page}\n onSortingChange={(sort) => setQuery((q) => ({ ...q, sort, page: 0 }))}\n onPaginationChange={(p) => setQuery((q) => ({ ...q, page: (p as { pageIndex: number }).pageIndex }))}\n />\n </div>\n )\n}\n```\n\n### Theming\n\nThe `--sg-*` custom properties. Ordinary CSS custom properties, so they cascade from any ancestor - which is why they also reach inside `<sv-grid-shadow>`.\n\n<div data-docs-mirror=\"theming\" data-height=\"470\"></div>\n\n<div data-docs-sandbox=\"react:theming\" data-title=\"Theming\"></div>\n\n```tsx {nocheck}\nimport { useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns } from '../data'\n\n/**\n * Theming through `--sg-*` custom properties.\n *\n * They are ordinary CSS custom properties, so they cascade: set them on any\n * ancestor and the grid inherits. That is also why they reach into\n * `<sv-grid-shadow>` - inheritance crosses a shadow boundary.\n */\nconst THEMES = {\n Light: {},\n Dark: {\n '--sg-bg': '#0b1220',\n '--sg-fg': '#e2e8f0',\n '--sg-border': '#1e293b',\n '--sg-header-bg': '#111a2e',\n '--sg-row-hover': '#111a2e',\n },\n Warm: {\n '--sg-bg': '#fffaf5',\n '--sg-fg': '#42302a',\n '--sg-border': '#f0dcc9',\n '--sg-header-bg': '#fdf1e4',\n '--sg-accent': '#c2410c',\n },\n} satisfies Record<string, Record<string, string>>\n\nexport default function App() {\n const [theme, setTheme] = useState<keyof typeof THEMES>('Light')\n\n return (\n <div\n style={{\n height: '100%',\n padding: 16,\n display: 'flex',\n flexDirection: 'column',\n gap: 12,\n ...THEMES[theme],\n }}\n >\n <div style={{ display: 'flex', gap: 8 }}>\n {(Object.keys(THEMES) as Array<keyof typeof THEMES>).map((name) => (\n <button\n key={name}\n onClick={() => setTheme(name)}\n style={{\n padding: '4px 10px',\n borderRadius: 6,\n border: '1px solid #cbd5e1',\n background: theme === name ? '#e2e8f0' : '#fff',\n cursor: 'pointer',\n }}\n >\n {name}\n </button>\n ))}\n </div>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid data={people} columns={columns} sortable filterable zebraRows />\n </div>\n </div>\n )\n}\n```\n\n### Excel export (Enterprise)\n\nThe paid pack from a non-Svelte host. `@svgrid/enterprise/export` is plain JavaScript, so it needs no Svelte in your build - the same goes for `/import`, `/print`, `/pivot` and `/license`. See [Enterprise features](./enterprise.md) for what those subpaths cover and what needs a Svelte-aware bundler.\n\n<div data-docs-mirror=\"enterprise\" data-height=\"460\"></div>\n\n<div data-docs-sandbox=\"react:enterprise\" data-title=\"Excel export (Enterprise)\"></div>\n\n```tsx {nocheck}\nimport { useRef, useState } from 'react'\nimport { SvGrid, type SvGridHandle } from '@svgrid/grid-wc/react'\nimport { exportGrid } from '@svgrid/enterprise/export'\nimport { people, columns } from '../data'\n\n/**\n * Excel export from a React host.\n *\n * `@svgrid/enterprise/export` is plain JavaScript - no Svelte in your build -\n * so this works in any bundler. The same is true of `/import`, `/print`,\n * `/pivot` and `/license`. The Kanban and scheduler views are the exception:\n * they are Svelte components, so they need a Svelte-aware bundler.\n *\n * The api arrives on the handle once `apiready` has fired. It is typed\n * `unknown` there because the wrapper does not depend on the grid's types, so\n * the enterprise call needs a cast.\n */\nexport default function App() {\n const grid = useRef<SvGridHandle>(null)\n const [ready, setReady] = useState(false)\n const [busy, setBusy] = useState(false)\n\n async function exportXlsx() {\n if (!grid.current?.api) return\n setBusy(true)\n try {\n await exportGrid(grid.current.api as Parameters<typeof exportGrid>[0], {\n format: 'xlsx',\n filename: 'people',\n })\n } finally {\n setBusy(false)\n }\n }\n\n return (\n <div style={{ height: '100%', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>\n <button type=\"button\" onClick={exportXlsx} disabled={!ready || busy}>\n {busy ? 'Building…' : 'Export to Excel'}\n </button>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid\n ref={grid}\n data={people}\n columns={columns}\n sortable\n filterable\n onApiReady={() => setReady(true)}\n />\n </div>\n </div>\n )\n}\n```\n\n<!-- END generated examples -->\n\n## Rendering into cells\n\nYou cannot put a React component inside a cell: cell rendering is a Svelte\ncompile-time feature, and there is nothing to hand across the boundary. Column\n`format` options, `fieldFn` and HTML-string renderers cover badges, links and\nformatted values.\n\nColumn `format` and HTML-string renderers, which is what to reach for instead:\n\n<div data-docs-demo=\"10-custom-cells-and-themes\" data-height=\"480\"></div>\n\nIf a grid's cells must render your own components, use the Svelte component\ndirectly. See [limitations](./limitations.md).\n\n## Why it lives in `@svgrid/grid-wc`\n\nRather than a separate `@svgrid/react` package: one version to keep in step\nwith the element instead of two, and `react` is an OPTIONAL peer dependency, so\na plain-HTML consumer never installs React. The wrapper is a subpath import and\nabout 1.4 KB - it reuses the one element bundle rather than shipping a second\ncopy of the grid.\n\n## The raw element\n\n`<SvGrid>` wraps `<sv-grid>`, and nothing stops you using the element\ndirectly - it is the same grid, and the wrapper is a thin layer over it. You\nwould be taking on the property assignment, the event binding and the two\nordering problems the wrapper handles, so do it only if you have a reason to.\n\n[Quick start](./quick-start.md) covers the element, and the\n[reference](./sv-grid.md) lists every property, attribute and event.\n\n## See also\n\n- [All frameworks](./frameworks.md) - the same examples in Vue and Angular.\n- [`<sv-grid>` reference](./sv-grid.md) - every property, attribute and event.\n- [TypeScript](./typescript.md) - typing the element, if you use it directly.\n- [Limitations](./limitations.md) - what cannot cross the boundary.\n"
6033
6048
  },
6034
6049
  {
6035
6050
  "slug": "help/web-components/shadow-dom",
6036
6051
  "path": "docs/help/web-components/shadow-dom.md",
6037
6052
  "title": "Shadow DOM: <sv-grid-shadow>",
6038
6053
  "section": "help/web-components",
6039
- "markdown": "# Shadow DOM: `<sv-grid-shadow>`\n\nTwo elements ship, and which one you want is a styling decision:\n\n| Element | Renders in | Page CSS | Use when |\n| --- | --- | --- | --- |\n| `<sv-grid>` | the light DOM | applies normally | Almost always - a data grid usually wants to inherit your app's theme. |\n| `<sv-grid-shadow>` | an open shadow root | **cannot reach the grid** | You are embedding in a page whose CSS you do not control, or shipping into someone else's site. |\n\nThey are two elements rather than one with a `shadow` attribute because Svelte's\n`customElement.shadow` is resolved when the component is COMPILED. No runtime\nflag can switch it, so no attribute can either.\n\n```html\n<script type=\"module\" src=\"https://unpkg.com/@svgrid/grid-wc/dist/shadow/sv-grid-shadow-element.js\"></script>\n\n<sv-grid-shadow id=\"grid\" sortable filterable style=\"display:block;height:420px\"></sv-grid-shadow>\n<script type=\"module\">\n const grid = document.getElementById('grid')\n grid.columns = [{ field: 'name', header: 'Name' }, { field: 'city', header: 'City' }]\n grid.data = [{ name: 'Ada', city: 'London' }, { name: 'Linus', city: 'Helsinki' }]\n</script>\n```\n\nEverything else is identical: the same 115 properties, the same 77 attributes,\nthe same 27 events. Events are dispatched `composed: true`, so\n`addEventListener` on the host works exactly as it does on `<sv-grid>`.\n\n\n## What the shadow root does and does not isolate\n\nTheming with `--sg-*` tokens, which reach into the shadow root by inheritance.\n\n<div data-docs-demo=\"10-custom-cells-and-themes\" data-height=\"500\"></div>\n\nThe root is **open**, never closed. A closed root leaves `el.shadowRoot` null,\nwhich would stop you styling, querying, or testing your own grid - so it is not\noffered.\n\nIsolation is one-directional, and worth being precise about:\n\n- **Page CSS cannot reach the grid.** A page rule like\n `table td { background: fuchsia }` hits `<sv-grid>` and stops at\n `<sv-grid-shadow>`'s boundary. That is the point of the element.\n- **The grid's CSS is still in your page.** About twenty overlay surfaces - the\n cell dropdown, the date picker, tooltips, toasts, modals - portal to\n `document.body` deliberately, so they escape every ancestor `overflow` and\n clip. Those land outside the shadow root, so the bundle injects its stylesheet\n into `document.head` as well as adopting it into the root. Every rule is\n scoped to an `.sv-grid-*` class, so it does not style your page, but it is\n present in it.\n- **Styles arrive with the code that needs them.** The entry carries the\n grid's own sheet; a lazy chunk (the chart, the date pickers, the menus)\n carries the styles of the components inside it and adds them to the page\n and to every open root when it loads. A page that never charts never\n downloads the chart's styles.\n\n## Theming\n\n`--sg-*` tokens work in both elements, unchanged:\n\n```css\nsv-grid, sv-grid-shadow {\n --sg-bg: #fff;\n --sg-header-bg: #f8fafc;\n}\n```\n\nA theme stylesheet works too, including its dark variant, even though its\nselectors are `:root` and `:root[data-theme='dark']` and neither can MATCH\ninside a shadow tree. Custom properties are *inherited* properties, and\ninheritance crosses a shadow boundary - so the values land on the host and are\ninherited by everything in the root. Verified in a browser rather than reasoned\nabout: it was assumed to be broken for a while, and it is not.\n\n\n## Constraints\n\nSelection, clipboard and the menus that portal out of the root - all identical inside a shadow tree.\n\n<div data-docs-demo=\"04-selection-copy-paste\" data-height=\"460\"></div>\n\n- **The root is resolved on mount.** Moving a live `<sv-grid-shadow>` into a\n different document needs a fresh element, not a reparent.\n- **Do not give the host a `transform`, `filter`, or `contain`.** Any of those\n makes it a containing block for fixed positioning, and the grid's menus -\n which are `position: fixed` - would anchor to the host instead of the\n viewport.\n- **Closed roots are unsupported**, for the reason above.\n\n\n## See also\n\n- [Quick start](./quick-start.md)\n- [`<sv-grid>` reference](./sv-grid.md)\n- [Limitations](./limitations.md)\n"
6054
+ "markdown": "# Shadow DOM: `<sv-grid-shadow>`\n\nTwo elements ship, and which one you want is a styling decision:\n\n| Element | Renders in | Page CSS | Use when |\n| --- | --- | --- | --- |\n| `<sv-grid>` | the light DOM | applies normally | Almost always - a data grid usually wants to inherit your app's theme. |\n| `<sv-grid-shadow>` | an open shadow root | **cannot reach the grid** | You are embedding in a page whose CSS you do not control, or shipping into someone else's site. |\n\nThey are two elements rather than one with a `shadow` attribute because Svelte's\n`customElement.shadow` is resolved when the component is COMPILED. No runtime\nflag can switch it, so no attribute can either.\n\n```html\n<script type=\"module\" src=\"https://unpkg.com/@svgrid/grid-wc/dist/shadow/sv-grid-shadow-element.js\"></script>\n\n<sv-grid-shadow id=\"grid\" sortable filterable style=\"display:block;height:420px\"></sv-grid-shadow>\n<script type=\"module\">\n const grid = document.getElementById('grid')\n grid.columns = [{ field: 'name', header: 'Name' }, { field: 'city', header: 'City' }]\n grid.data = [{ name: 'Ada', city: 'London' }, { name: 'Linus', city: 'Helsinki' }]\n</script>\n```\n\nEverything else is identical: the same 116 properties, the same 78 attributes,\nthe same 27 events. Events are dispatched `composed: true`, so\n`addEventListener` on the host works exactly as it does on `<sv-grid>`.\n\n\n## What the shadow root does and does not isolate\n\nTheming with `--sg-*` tokens, which reach into the shadow root by inheritance.\n\n<div data-docs-demo=\"10-custom-cells-and-themes\" data-height=\"500\"></div>\n\nThe root is **open**, never closed. A closed root leaves `el.shadowRoot` null,\nwhich would stop you styling, querying, or testing your own grid - so it is not\noffered.\n\nIsolation is one-directional, and worth being precise about:\n\n- **Page CSS cannot reach the grid.** A page rule like\n `table td { background: fuchsia }` hits `<sv-grid>` and stops at\n `<sv-grid-shadow>`'s boundary. That is the point of the element.\n- **The grid's CSS is still in your page.** About twenty overlay surfaces - the\n cell dropdown, the date picker, tooltips, toasts, modals - portal to\n `document.body` deliberately, so they escape every ancestor `overflow` and\n clip. Those land outside the shadow root, so the bundle injects its stylesheet\n into `document.head` as well as adopting it into the root. Every rule is\n scoped to an `.sv-grid-*` class, so it does not style your page, but it is\n present in it.\n- **Styles arrive with the code that needs them.** The entry carries the\n grid's own sheet; a lazy chunk (the chart, the date pickers, the menus)\n carries the styles of the components inside it and adds them to the page\n and to every open root when it loads. A page that never charts never\n downloads the chart's styles.\n\n## Theming\n\n`--sg-*` tokens work in both elements, unchanged:\n\n```css\nsv-grid, sv-grid-shadow {\n --sg-bg: #fff;\n --sg-header-bg: #f8fafc;\n}\n```\n\nA theme stylesheet works too, including its dark variant, even though its\nselectors are `:root` and `:root[data-theme='dark']` and neither can MATCH\ninside a shadow tree. Custom properties are *inherited* properties, and\ninheritance crosses a shadow boundary - so the values land on the host and are\ninherited by everything in the root. Verified in a browser rather than reasoned\nabout: it was assumed to be broken for a while, and it is not.\n\n\n## Constraints\n\nSelection, clipboard and the menus that portal out of the root - all identical inside a shadow tree.\n\n<div data-docs-demo=\"04-selection-copy-paste\" data-height=\"460\"></div>\n\n- **The root is resolved on mount.** Moving a live `<sv-grid-shadow>` into a\n different document needs a fresh element, not a reparent.\n- **Do not give the host a `transform`, `filter`, or `contain`.** Any of those\n makes it a containing block for fixed positioning, and the grid's menus -\n which are `position: fixed` - would anchor to the host instead of the\n viewport.\n- **Closed roots are unsupported**, for the reason above.\n\n\n## See also\n\n- [Quick start](./quick-start.md)\n- [`<sv-grid>` reference](./sv-grid.md)\n- [Limitations](./limitations.md)\n"
6040
6055
  },
6041
6056
  {
6042
6057
  "slug": "help/web-components/sv-chart",
@@ -6050,7 +6065,7 @@ export const docs = [
6050
6065
  "path": "docs/help/web-components/sv-grid.md",
6051
6066
  "title": "<sv-grid> reference",
6052
6067
  "section": "help/web-components",
6053
- "markdown": "# `<sv-grid>` reference\n\nEvery property, attribute and event of the custom element.\n\n**This page is generated** from `<SvGrid>`'s own `Props` type by\n`packages/grid-wc/scripts/generate-surface.mjs`, and CI fails if it drifts. The\nelement used to declare 7 props and 2 events by hand while the docs claimed\n\"grouping, sorting, pagination all come along\" - a hand-written table is how\nthat happens.\n\n## Attributes vs properties\n\n`groupable` is an attribute; `groupBy` is an array, so it is property-only. This grid uses both.\n\n<div data-docs-demo=\"07-grouping-aggregation\" data-height=\"480\"></div>\n\nThe distinction matters more here than anywhere else in the API:\n\n- **An attribute is a string.** Primitives (`boolean`, `number`, `string`) can\n be written in HTML: `<sv-grid page-size=\"25\" show-row-numbers>`.\n- **Arrays, objects and functions cannot.** `columns='[object Object]'` is not\n a thing. Assign them in script: `el.columns = [...]`.\n\nBoth work through the property, so `el.pageSize = 25` is equally valid. The\nattribute is the extra convenience, offered only where it can work.\n\n```html\n<sv-grid id=\"grid\" sortable filterable page-size=\"25\" row-height=\"32\"></sv-grid>\n<script type=\"module\">\n const grid = document.getElementById('grid')\n grid.columns = [{ field: 'name', header: 'Name' }] // property: an array\n grid.data = rows // property: an array\n</script>\n```\n\n<!-- BEGIN generated reference - packages/grid-wc/scripts/generate-surface.mjs -->\n\n### Attributes (77)\n\nPrimitives, so they work in plain HTML as well as through a property.\n\n| Attribute | Property | Type |\n| --- | --- | --- |\n| `pivot-mode` | `pivotMode` | `boolean` |\n| `context-menu` | `contextMenu` | `boolean \\| ReadonlyArray<ContextMenuItem<TData>>` |\n| `selection-bar` | `selectionBar` | `\\| boolean \\| ReadonlyArray<SelectionBarAction<TData> \\| SelectionBarBuiltin> \\| SelectionB...` |\n| `sortable` | `sortable` | `boolean` |\n| `filterable` | `filterable` | `boolean` |\n| `editable` | `editable` | `boolean` |\n| `groupable` | `groupable` | `boolean` |\n| `group-footers` | `groupFooters` | `boolean` |\n| `grand-total-row` | `grandTotalRow` | `boolean` |\n| `group-display-mode` | `groupDisplayMode` | `GroupDisplayType` |\n| `auto-group-column-header` | `autoGroupColumnHeader` | `string` |\n| `auto-group-column-width` | `autoGroupColumnWidth` | `number` |\n| `pageable` | `pageable` | `boolean` |\n| `loading` | `loading` | `boolean` |\n| `loading-overlay` | `loadingOverlay` | `boolean` |\n| `loading-skeleton-rows` | `loadingSkeletonRows` | `number` |\n| `error` | `error` | `string \\| null` |\n| `empty-message` | `emptyMessage` | `string` |\n| `show-global-filter` | `showGlobalFilter` | `boolean` |\n| `show-column-filters` | `showColumnFilters` | `boolean` |\n| `filter-mode` | `filterMode` | `\"row\" \\| \"menu\" \\| \"global\" \\| \"none\"` |\n| `show-grouping-controls` | `showGroupingControls` | `boolean` |\n| `show-row-selection` | `showRowSelection` | `boolean` |\n| `show-pagination` | `showPagination` | `boolean` |\n| `page-size` | `pageSize` | `number` |\n| `pagination-position` | `paginationPosition` | `'top' \\| 'bottom' \\| 'both'` |\n| `external-pagination` | `externalPagination` | `boolean` |\n| `row-count` | `rowCount` | `number` |\n| `page-index` | `pageIndex` | `number` |\n| `virtualization` | `virtualization` | `boolean` |\n| `row-height` | `rowHeight` | `number \\| ((rowIndex: number) => number)` |\n| `auto-row-height` | `autoRowHeight` | `boolean` |\n| `row-resize` | `rowResize` | `boolean` |\n| `header-height` | `headerHeight` | `number` |\n| `overscan` | `overscan` | `number` |\n| `container-height` | `containerHeight` | `number \\| string` |\n| `column-virtualization` | `columnVirtualization` | `boolean` |\n| `column-overscan` | `columnOverscan` | `number` |\n| `column-width` | `columnWidth` | `number` |\n| `fit-columns` | `fitColumns` | `boolean` |\n| `column-resize` | `columnResize` | `boolean` |\n| `responsive` | `responsive` | `boolean \\| { breakpoint?: number }` |\n| `show-filter-menu` | `showFilterMenu` | `boolean` |\n| `show-filter-row` | `showFilterRow` | `boolean` |\n| `enable-cell-selection` | `enableCellSelection` | `boolean` |\n| `move-cells` | `moveCells` | `boolean` |\n| `enable-row-hover` | `enableRowHover` | `boolean` |\n| `copy-headers-to-clipboard` | `copyHeadersToClipboard` | `boolean` |\n| `enable-inline-editing` | `enableInlineEditing` | `boolean` |\n| `edit-on-second-click` | `editOnSecondClick` | `boolean` |\n| `full-row-editing` | `fullRowEditing` | `boolean` |\n| `enable-row-summaries` | `enableRowSummaries` | `boolean` |\n| `summary` | `summary` | `boolean` |\n| `status-bar` | `statusBar` | `\\| boolean \\| { ... }` |\n| `tool-panel` | `toolPanel` | `boolean` |\n| `charting` | `charting` | `boolean \\| ChartingConfig<TData>` |\n| `column-menu-tabs` | `columnMenuTabs` | `boolean` |\n| `tool-panel-default-open` | `toolPanelDefaultOpen` | `boolean` |\n| `tool-panel-default-tab` | `toolPanelDefaultTab` | `\"columns\" \\| \"filters\"` |\n| `selection-mode` | `selectionMode` | `\"row\" \\| \"cell\" \\| \"both\" \\| \"none\"` |\n| `show-row-numbers` | `showRowNumbers` | `boolean` |\n| `zebra-rows` | `zebraRows` | `boolean` |\n| `row-number-width` | `rowNumberWidth` | `number` |\n| `external-sort` | `externalSort` | `boolean` |\n| `external-filter` | `externalFilter` | `boolean` |\n| `editable-comments` | `editableComments` | `boolean` |\n| `conditional-stat-scope` | `conditionalStatScope` | `\"filtered\" \\| \"visible\" \\| \"all\"` |\n| `detail-row-height` | `detailRowHeight` | `number \\| ((row: TData) => number)` |\n| `show-detail-toggle` | `showDetailToggle` | `boolean` |\n| `frozen-rows` | `frozenRows` | `number` |\n| `sticky-group-rows` | `stickyGroupRows` | `boolean` |\n| `enable-column-reorder` | `enableColumnReorder` | `boolean` |\n| `infer-column-types` | `inferColumnTypes` | `boolean` |\n| `row-drag-managed` | `rowDragManaged` | `boolean` |\n| `row-drag-group` | `rowDragGroup` | `string` |\n| `aligned-grid-group` | `alignedGridGroup` | `string` |\n| `filter-locale` | `filterLocale` | `string \\| ReadonlyArray<string>` |\n\n### Properties only (38)\n\nArrays, objects and functions. An HTML attribute is a string, so these can\nonly be assigned in script: `el.columns = [...]`.\n\n| Property | Type |\n| --- | --- |\n| `data` | `ReadonlyArray<TData>` |\n| `columns` | `Array<ColumnDef<TFeatures, TData>>` |\n| `board` | `BoardConfig<TFeatures, TData>` |\n| `scheduler` | `SchedulerConfig<TFeatures, TData>` |\n| `gantt` | `GanttConfig<TFeatures, TData>` |\n| `chart` | `ChartViewConfig<TFeatures, TData>` |\n| `pivot` | `GridPivotConfig<TData>` |\n| `features` | `TFeatures` |\n| `treeData` | `{ ... }` |\n| `groupBy` | `ReadonlyArray<string>` |\n| `expanded` | `Record<string, boolean>` |\n| `localization` | `GridLocalization` |\n| `pageSizeOptions` | `number[]` |\n| `initialColumnPinning` | `{ ... }` |\n| `processCellForFill` | `(params: { value: unknown; delta: { rows: number; cols: number }; rowIndex: number; col...` |\n| `processCellForClipboard` | `(params: { ... }) => unknown` |\n| `includeCollapsedRows` | `(rowIndex: number) => boolean` |\n| `clipboardHtml` | `(params: { rects: ReadonlyArray<{ ... }>; text: string; }) => string \\| null \\| undefined` |\n| `processCellFromClipboard` | `(params: { ... }) => unknown` |\n| `initialSorting` | `Array<{ id: string; desc: boolean }>` |\n| `initialAdvancedFilter` | `GridPredicateExpr \\| null` |\n| `getRowId` | `(row: TData, index: number) => string` |\n| `rowClass` | `(ctx: { row: TData; rowIndex: number; }) => string \\| ReadonlyArray<string> \\| Record<str...` |\n| `notes` | `Record<string, Record<string, string>>` |\n| `conditionalFormats` | `ReadonlyArray<ConditionalFormat<TData>>` |\n| `rowPlaceholder` | `(row: TData, rowIndex: number) => \"loading\" \\| \"failed\" \\| null` |\n| `rowModel` | `GridRowModel<TData>` |\n| `pivotResultColumns` | `Array<ColumnDef<TFeatures, TData>> \\| null` |\n| `rowSelectionModel` | `{ ... }` |\n| `isDetailRow` | `(row: TData, rowIndex: number) => boolean` |\n| `isDetailOpen` | `(row: TData) => boolean` |\n| `hasDetail` | `(row: TData) => boolean` |\n| `serverGroup` | `{ ... }` |\n| `serverFilterValues` | `(columnId: string) => Promise<string[]>` |\n| `pinnedTopRows` | `ReadonlyArray<TData>` |\n| `mergedCells` | `ReadonlyArray<{ ... }>` |\n| `pinnedBottomRows` | `ReadonlyArray<TData>` |\n| `columnOrder` | `ReadonlyArray<string>` |\n\n### Events (26)\n\n`detail` is the callback's argument. The one callback that takes two carries\nan object keyed by its parameter names.\n\n| Event | From | `detail` |\n| --- | --- | --- |\n| `pivotmodechange` | `onPivotModeChange` | `on` |\n| `expandedchange` | `onExpandedChange` | `expanded` |\n| `paginationchange` | `onPaginationChange` | `pagination` |\n| `columnresize` | `onColumnResize` | `event` |\n| `rowresize` | `onRowResize` | `event` |\n| `pasteclipboard` | `onPasteClipboard` | `payload` |\n| `apiready` | `onApiReady` | `api` |\n| `rowselectionchange` | `onRowSelectionChange` | `{ selection, rows }` |\n| `cellselectionchange` | `onCellSelectionChange` | `ranges` |\n| `sortingchange` | `onSortingChange` | `sorting` |\n| `advancedfilterchange` | `onAdvancedFilterChange` | `expr` |\n| `filterschange` | `onFiltersChange` | `filters` |\n| `notechange` | `onNoteChange` | `event` |\n| `cellvaluechange` | `onCellValueChange` | `event` |\n| `activecellchange` | `onActiveCellChange` | `cell` |\n| `cellclick` | `onCellClick` | `event` |\n| `rowclick` | `onRowClick` | `row` - published detail is the row, not the whole click event |\n| `celldoubleclick` | `onCellDoubleClick` | `event` |\n| `rowdoubleclick` | `onRowDoubleClick` | `event` |\n| `scrollbottomreached` | `onScrollBottomReached` | `event` |\n| `visiblerangechange` | `onVisibleRangeChange` | `range` |\n| `retryrow` | `onRetryRow` | `{ row, rowIndex }` |\n| `detailtoggle` | `onDetailToggle` | `{ row, rowIndex }` |\n| `columnorderchange` | `onColumnOrderChange` | `order` |\n| `rowdragend` | `onRowDragEnd` | `event` |\n| `rowdrop` | `onRowDrop` | `event` |\n\nPlus one alias kept from before the surface was generated:\n\n| Event | From | `detail` |\n| --- | --- | --- |\n| `selectionchange` | `onRowSelectionChange` | `rows` - published alias of rowselectionchange, detail is the selected rows |\n\n### Not exposed (3)\n\n| Prop | Why |\n| --- | --- |\n| `selectable` | element-level shorthand for row selection - see GridBody.svelte |\n| `icons` | Svelte snippets keyed by icon name - cannot cross the custom-element boundary |\n| `renderDetailRow` | Svelte snippet - cannot cross the custom-element boundary |\n\n<!-- END generated reference -->\n\n\n## The imperative API\n\nThe api surface `apiready` hands you.\n\n<div data-docs-demo=\"90-selection-api\" data-height=\"460\"></div>\n\n`onApiReady` fires once with the grid's api. Because a listener bound after\nmount would miss it, the handle is also parked on the element:\n\n```js\ngrid.addEventListener('apiready', (e) => e.detail.exportCsv())\n// or, at any time later:\ngrid.api.exportCsv()\n```\n\n\n## See also\n\n- [Quick start](./quick-start.md) - the CDN path, no build step.\n- [Limitations](./limitations.md) - what cannot cross the boundary.\n- [Shadow DOM](./shadow-dom.md) - `<sv-grid-shadow>` and style isolation.\n"
6068
+ "markdown": "# `<sv-grid>` reference\n\nEvery property, attribute and event of the custom element.\n\n**This page is generated** from `<SvGrid>`'s own `Props` type by\n`packages/grid-wc/scripts/generate-surface.mjs`, and CI fails if it drifts. The\nelement used to declare 7 props and 2 events by hand while the docs claimed\n\"grouping, sorting, pagination all come along\" - a hand-written table is how\nthat happens.\n\n## Attributes vs properties\n\n`groupable` is an attribute; `groupBy` is an array, so it is property-only. This grid uses both.\n\n<div data-docs-demo=\"07-grouping-aggregation\" data-height=\"480\"></div>\n\nThe distinction matters more here than anywhere else in the API:\n\n- **An attribute is a string.** Primitives (`boolean`, `number`, `string`) can\n be written in HTML: `<sv-grid page-size=\"25\" show-row-numbers>`.\n- **Arrays, objects and functions cannot.** `columns='[object Object]'` is not\n a thing. Assign them in script: `el.columns = [...]`.\n\nBoth work through the property, so `el.pageSize = 25` is equally valid. The\nattribute is the extra convenience, offered only where it can work.\n\n```html\n<sv-grid id=\"grid\" sortable filterable page-size=\"25\" row-height=\"32\"></sv-grid>\n<script type=\"module\">\n const grid = document.getElementById('grid')\n grid.columns = [{ field: 'name', header: 'Name' }] // property: an array\n grid.data = rows // property: an array\n</script>\n```\n\n<!-- BEGIN generated reference - packages/grid-wc/scripts/generate-surface.mjs -->\n\n### Attributes (78)\n\nPrimitives, so they work in plain HTML as well as through a property.\n\n| Attribute | Property | Type |\n| --- | --- | --- |\n| `pivot-mode` | `pivotMode` | `boolean` |\n| `context-menu` | `contextMenu` | `boolean \\| ReadonlyArray<ContextMenuItem<TData>>` |\n| `selection-bar` | `selectionBar` | `\\| boolean \\| ReadonlyArray<SelectionBarAction<TData> \\| SelectionBarBuiltin> \\| SelectionB...` |\n| `sortable` | `sortable` | `boolean` |\n| `filterable` | `filterable` | `boolean` |\n| `editable` | `editable` | `boolean` |\n| `groupable` | `groupable` | `boolean` |\n| `group-footers` | `groupFooters` | `boolean` |\n| `grand-total-row` | `grandTotalRow` | `boolean` |\n| `group-display-mode` | `groupDisplayMode` | `GroupDisplayType` |\n| `auto-group-column-header` | `autoGroupColumnHeader` | `string` |\n| `auto-group-column-width` | `autoGroupColumnWidth` | `number` |\n| `pageable` | `pageable` | `boolean` |\n| `loading` | `loading` | `boolean` |\n| `loading-overlay` | `loadingOverlay` | `boolean` |\n| `loading-skeleton-rows` | `loadingSkeletonRows` | `number` |\n| `error` | `error` | `string \\| null` |\n| `empty-message` | `emptyMessage` | `string` |\n| `show-global-filter` | `showGlobalFilter` | `boolean` |\n| `show-column-filters` | `showColumnFilters` | `boolean` |\n| `filter-mode` | `filterMode` | `\"row\" \\| \"menu\" \\| \"global\" \\| \"none\"` |\n| `show-grouping-controls` | `showGroupingControls` | `boolean` |\n| `show-row-selection` | `showRowSelection` | `boolean` |\n| `show-pagination` | `showPagination` | `boolean` |\n| `page-size` | `pageSize` | `number` |\n| `pagination-position` | `paginationPosition` | `'top' \\| 'bottom' \\| 'both'` |\n| `external-pagination` | `externalPagination` | `boolean` |\n| `row-count` | `rowCount` | `number` |\n| `page-index` | `pageIndex` | `number` |\n| `virtualization` | `virtualization` | `boolean` |\n| `row-height` | `rowHeight` | `number \\| ((rowIndex: number) => number)` |\n| `auto-row-height` | `autoRowHeight` | `boolean` |\n| `row-resize` | `rowResize` | `boolean` |\n| `header-height` | `headerHeight` | `number` |\n| `overscan` | `overscan` | `number` |\n| `container-height` | `containerHeight` | `number \\| string` |\n| `column-virtualization` | `columnVirtualization` | `boolean` |\n| `column-overscan` | `columnOverscan` | `number` |\n| `column-width` | `columnWidth` | `number` |\n| `fit-columns` | `fitColumns` | `boolean` |\n| `column-resize` | `columnResize` | `boolean` |\n| `responsive` | `responsive` | `boolean \\| { breakpoint?: number }` |\n| `show-filter-menu` | `showFilterMenu` | `boolean` |\n| `show-filter-row` | `showFilterRow` | `boolean` |\n| `enable-cell-selection` | `enableCellSelection` | `boolean` |\n| `move-cells` | `moveCells` | `boolean` |\n| `enable-row-hover` | `enableRowHover` | `boolean` |\n| `copy-headers-to-clipboard` | `copyHeadersToClipboard` | `boolean` |\n| `enable-inline-editing` | `enableInlineEditing` | `boolean` |\n| `edit-on-second-click` | `editOnSecondClick` | `boolean` |\n| `full-row-editing` | `fullRowEditing` | `boolean` |\n| `enable-row-summaries` | `enableRowSummaries` | `boolean` |\n| `summary` | `summary` | `boolean` |\n| `status-bar` | `statusBar` | `\\| boolean \\| { ... }` |\n| `tool-panel` | `toolPanel` | `boolean` |\n| `charting` | `charting` | `boolean \\| ChartingConfig<TData>` |\n| `column-menu-tabs` | `columnMenuTabs` | `boolean` |\n| `tool-panel-default-open` | `toolPanelDefaultOpen` | `boolean` |\n| `tool-panel-default-tab` | `toolPanelDefaultTab` | `\"columns\" \\| \"filters\"` |\n| `selection-mode` | `selectionMode` | `\"row\" \\| \"cell\" \\| \"both\" \\| \"none\"` |\n| `show-row-numbers` | `showRowNumbers` | `boolean` |\n| `zebra-rows` | `zebraRows` | `boolean` |\n| `row-number-width` | `rowNumberWidth` | `number` |\n| `external-sort` | `externalSort` | `boolean` |\n| `external-filter` | `externalFilter` | `boolean` |\n| `editable-comments` | `editableComments` | `boolean` |\n| `conditional-stat-scope` | `conditionalStatScope` | `\"filtered\" \\| \"visible\" \\| \"all\"` |\n| `detail-row-height` | `detailRowHeight` | `number \\| ((row: TData) => number)` |\n| `show-detail-toggle` | `showDetailToggle` | `boolean` |\n| `frozen-rows` | `frozenRows` | `number` |\n| `sticky-group-rows` | `stickyGroupRows` | `boolean` |\n| `row-layout` | `rowLayout` | `\"flex\" \\| \"table\"` |\n| `enable-column-reorder` | `enableColumnReorder` | `boolean` |\n| `infer-column-types` | `inferColumnTypes` | `boolean` |\n| `row-drag-managed` | `rowDragManaged` | `boolean` |\n| `row-drag-group` | `rowDragGroup` | `string` |\n| `aligned-grid-group` | `alignedGridGroup` | `string` |\n| `filter-locale` | `filterLocale` | `string \\| ReadonlyArray<string>` |\n\n### Properties only (38)\n\nArrays, objects and functions. An HTML attribute is a string, so these can\nonly be assigned in script: `el.columns = [...]`.\n\n| Property | Type |\n| --- | --- |\n| `data` | `ReadonlyArray<TData>` |\n| `columns` | `Array<ColumnDef<TFeatures, TData>>` |\n| `board` | `BoardConfig<TFeatures, TData>` |\n| `scheduler` | `SchedulerConfig<TFeatures, TData>` |\n| `gantt` | `GanttConfig<TFeatures, TData>` |\n| `chart` | `ChartViewConfig<TFeatures, TData>` |\n| `pivot` | `GridPivotConfig<TData>` |\n| `features` | `TFeatures` |\n| `treeData` | `{ ... }` |\n| `groupBy` | `ReadonlyArray<string>` |\n| `expanded` | `Record<string, boolean>` |\n| `localization` | `GridLocalization` |\n| `pageSizeOptions` | `number[]` |\n| `initialColumnPinning` | `{ ... }` |\n| `processCellForFill` | `(params: { value: unknown; delta: { rows: number; cols: number }; rowIndex: number; col...` |\n| `processCellForClipboard` | `(params: { ... }) => unknown` |\n| `includeCollapsedRows` | `(rowIndex: number) => boolean` |\n| `clipboardHtml` | `(params: { rects: ReadonlyArray<{ ... }>; text: string; }) => string \\| null \\| undefined` |\n| `processCellFromClipboard` | `(params: { ... }) => unknown` |\n| `initialSorting` | `Array<{ id: string; desc: boolean }>` |\n| `initialAdvancedFilter` | `GridPredicateExpr \\| null` |\n| `getRowId` | `(row: TData, index: number) => string` |\n| `rowClass` | `(ctx: { row: TData; rowIndex: number; }) => string \\| ReadonlyArray<string> \\| Record<str...` |\n| `notes` | `Record<string, Record<string, string>>` |\n| `conditionalFormats` | `ReadonlyArray<ConditionalFormat<TData>>` |\n| `rowPlaceholder` | `(row: TData, rowIndex: number) => \"loading\" \\| \"failed\" \\| null` |\n| `rowModel` | `GridRowModel<TData>` |\n| `pivotResultColumns` | `Array<ColumnDef<TFeatures, TData>> \\| null` |\n| `rowSelectionModel` | `{ ... }` |\n| `isDetailRow` | `(row: TData, rowIndex: number) => boolean` |\n| `isDetailOpen` | `(row: TData) => boolean` |\n| `hasDetail` | `(row: TData) => boolean` |\n| `serverGroup` | `{ ... }` |\n| `serverFilterValues` | `(columnId: string) => Promise<string[]>` |\n| `pinnedTopRows` | `ReadonlyArray<TData>` |\n| `mergedCells` | `ReadonlyArray<{ ... }>` |\n| `pinnedBottomRows` | `ReadonlyArray<TData>` |\n| `columnOrder` | `ReadonlyArray<string>` |\n\n### Events (26)\n\n`detail` is the callback's argument. The one callback that takes two carries\nan object keyed by its parameter names.\n\n| Event | From | `detail` |\n| --- | --- | --- |\n| `pivotmodechange` | `onPivotModeChange` | `on` |\n| `expandedchange` | `onExpandedChange` | `expanded` |\n| `paginationchange` | `onPaginationChange` | `pagination` |\n| `columnresize` | `onColumnResize` | `event` |\n| `rowresize` | `onRowResize` | `event` |\n| `pasteclipboard` | `onPasteClipboard` | `payload` |\n| `apiready` | `onApiReady` | `api` |\n| `rowselectionchange` | `onRowSelectionChange` | `{ selection, rows }` |\n| `cellselectionchange` | `onCellSelectionChange` | `ranges` |\n| `sortingchange` | `onSortingChange` | `sorting` |\n| `advancedfilterchange` | `onAdvancedFilterChange` | `expr` |\n| `filterschange` | `onFiltersChange` | `filters` |\n| `notechange` | `onNoteChange` | `event` |\n| `cellvaluechange` | `onCellValueChange` | `event` |\n| `activecellchange` | `onActiveCellChange` | `cell` |\n| `cellclick` | `onCellClick` | `event` |\n| `rowclick` | `onRowClick` | `row` - published detail is the row, not the whole click event |\n| `celldoubleclick` | `onCellDoubleClick` | `event` |\n| `rowdoubleclick` | `onRowDoubleClick` | `event` |\n| `scrollbottomreached` | `onScrollBottomReached` | `event` |\n| `visiblerangechange` | `onVisibleRangeChange` | `range` |\n| `retryrow` | `onRetryRow` | `{ row, rowIndex }` |\n| `detailtoggle` | `onDetailToggle` | `{ row, rowIndex }` |\n| `columnorderchange` | `onColumnOrderChange` | `order` |\n| `rowdragend` | `onRowDragEnd` | `event` |\n| `rowdrop` | `onRowDrop` | `event` |\n\nPlus one alias kept from before the surface was generated:\n\n| Event | From | `detail` |\n| --- | --- | --- |\n| `selectionchange` | `onRowSelectionChange` | `rows` - published alias of rowselectionchange, detail is the selected rows |\n\n### Not exposed (3)\n\n| Prop | Why |\n| --- | --- |\n| `selectable` | element-level shorthand for row selection - see GridBody.svelte |\n| `icons` | Svelte snippets keyed by icon name - cannot cross the custom-element boundary |\n| `renderDetailRow` | Svelte snippet - cannot cross the custom-element boundary |\n\n<!-- END generated reference -->\n\n\n## The imperative API\n\nThe api surface `apiready` hands you.\n\n<div data-docs-demo=\"90-selection-api\" data-height=\"460\"></div>\n\n`onApiReady` fires once with the grid's api. Because a listener bound after\nmount would miss it, the handle is also parked on the element:\n\n```js\ngrid.addEventListener('apiready', (e) => e.detail.exportCsv())\n// or, at any time later:\ngrid.api.exportCsv()\n```\n\n\n## See also\n\n- [Quick start](./quick-start.md) - the CDN path, no build step.\n- [Limitations](./limitations.md) - what cannot cross the boundary.\n- [Shadow DOM](./shadow-dom.md) - `<sv-grid-shadow>` and style isolation.\n"
6054
6069
  },
6055
6070
  {
6056
6071
  "slug": "help/web-components/sv-sheet",
@@ -6379,7 +6394,7 @@ export const docs = [
6379
6394
  "path": "docs/reference/SvGrid.md",
6380
6395
  "title": "<SvGrid> reference",
6381
6396
  "section": "API reference",
6382
- "markdown": "# `<SvGrid>` reference\n\nThe render component. One `<SvGrid>` element per grid instance.\n\n```svelte\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n showPagination={true}\n pageSize={25}\n onApiReady={(api) => (gridApi = api)}\n/>\n```\n\n## Required props\n\n| Prop | Type | Notes |\n| ---------- | ------------------------------------------ | ---------------------------------------------------------------------- |\n| `data` | `ReadonlyArray<TData>` | The row source. Re-rendering on `data` reference change is automatic. |\n| `columns` | `Array<ColumnDef<TFeatures, TData>>` | See [ColumnDef reference](./ColumnDef.md). |\n| `features` | `TFeatures` | Build with `tableFeatures({...})`. See [features reference](./features.md). |\n\n## Data state\n\n| Prop | Type | Default | Notes |\n| -------------- | --------------------- | -------------------- | ----------------------------------------------------------- |\n| `loading` | `boolean` | `false` | Shows a built-in loading overlay. |\n| `error` | `string \\| null` | `null` | Shows an error banner inside the shell. |\n| `emptyMessage` | `string` | `\"No data\"` | Replaces the empty-state copy. |\n\n## Layout\n\n| Prop | Type | Default | Notes |\n| -------------------- | -------------------- | ----------- | ---------------------------------------------------------------------- |\n| `containerHeight` | `number \\| string` | `520` | Pixels (number) or any CSS height (string). Use `'100%'` inside a flex parent. |\n| `rowHeight` | `number \\| (i) => number` | `30` | In pixels. Drives virtualizer math when virtualization is on. Pass a function for per-row heights. Not a CSS token - the virtualizer needs a number, so it is written as an inline style per row. |\n| `columnWidth` | `number` | `140` | Default for columns without an explicit `width`. |\n| `fitColumns` | `boolean` | `false` | Scale columns proportionally to fill the viewport; residue absorbed in the last column. Shrinks down to 85% of natural widths; beyond that the user gets a horizontal scrollbar. |\n| `columnResize` | `boolean` | `false` | Drag a column edge to change its width, or focus the handle and use Left/Right (Shift = 1px). **Off by default**, so `width` is the width you asked for. Grid-wide, not per-column. `autosizeColumn` / `fitColumns` work either way. Handles load on demand. |\n| `rowResize` | `boolean` | `false` | Drag a row's bottom edge to change its height, or focus the grip and use Up/Down (Shift = 1px). The grid remembers the heights, so no `rowHeight` function is needed. **Turns on `showRowNumbers`**, since the grip lives in the row header; an explicit `showRowNumbers={false}` wins and the grip falls back to the row's first cell. Ignored under `autoRowHeight`. Grips load on demand. |\n| `showRowNumbers` | `boolean` | `false` | Leading 1-based row-number column. |\n| `rowNumberWidth` | `number` | `56` | Width (px) of the row-number column. Default fits up to \"99,999\"; bump it for six-figure row counts so the largest number stays fully visible at the bottom of a long scroll. |\n| `initialColumnPinning` | `{ left?, right? }`| - | Seed left/right pinning at mount. |\n\n## Icons\n\n| Prop | Type | Notes |\n| ------- | --------------------------------------- | ----- |\n| `icons` | `Partial<Record<GridIconName, Snippet>>` | Replace any of the glyphs the grid draws for its own chrome, keyed by name. Names you leave out keep their built-in glyph, so a partial map is all you ever need. Your markup is wrapped in the grid icon box, so it inherits the size of the glyph it replaced and the rotation an expander applies when it opens; use `currentColor` so it picks up hover / muted / accent states. See [Icons](../help/icons.md) for the full catalogue. |\n\n## Virtualization\n\n| Prop | Type | Default | Notes |\n| ---------------------- | --------- | ------- | -------------------------------------------------- |\n| `virtualization` | `boolean` | `true` | Row virtualization. |\n| `columnVirtualization` | `boolean` | `true` | Column virtualization. Disable for sticky-column pinning. |\n| `overscan` | `number` | `8` | Rows kept rendered above + below the viewport. |\n| `columnOverscan` | `number` | `3` | Columns kept rendered left + right of the viewport. |\n\n## Filter UI\n\n| Prop | Type | Default | Notes |\n| ------------------- | ------------------------------------------ | --------- | ---------------------------------------------------------------------- |\n| `filterMode` | `'menu' \\| 'row' \\| 'global' \\| 'none'` | `'menu'` | Umbrella prop. Overridden per-surface by the three below. |\n| `showGlobalFilter` | `boolean` | derived | Show the global search input. |\n| `showColumnFilters` | `boolean` | derived | Show the column-menu filter funnel. |\n| `showFilterRow` | `boolean` | derived | Show the per-column filter row under the header. |\n| `showFilterMenu` | `boolean` | derived | (Legacy alias for `showColumnFilters`.) |\n| `externalFilter` | `boolean` | `false` | Grid records filter UI state but does NOT filter rows. Pair with `onFiltersChange`. |\n\n## Selection\n\n| Prop | Type | Default | Notes |\n| -------------------- | ------------------------------------------ | ------- | ---------------------------------------------------- |\n| `selectionMode` | `'both' \\| 'row' \\| 'cell' \\| 'none'` | `'both'`| Umbrella prop. Overridden per-surface by the two below. |\n| `showRowSelection` | `boolean` | derived | Checkbox column. |\n| `enableCellSelection`| `boolean` | derived | Click-and-drag range selection. |\n\n## Editing\n\n| Prop | Type | Default | Notes |\n| --------------------- | --------- | ------- | ------------------------------------------------------ |\n| `enableInlineEditing` | `boolean` | `false` | Per-column `editorType` still required for a column to be editable. |\n| `enableRowSummaries` | `boolean` | `false` | Footer row aggregating every filtered row. Alias: `summary`. |\n\n## Sort\n\n| Prop | Type | Default | Notes |\n| -------------- | --------- | ------- | -------------------------------------------------------------- |\n| `externalSort` | `boolean` | `false` | Grid records sort state but does NOT re-order rows. Pair with `onSortingChange`. |\n\n## Pagination\n\n| Prop | Type | Default | Notes |\n| ---------------- | --------- | ------- | -------------------------------------------------------------- |\n| `showPagination` | `boolean` | `false` | Show the footer pager. |\n| `pageSize` | `number` | `10` | Initial page size. |\n\n## Grouping\n\n| Prop | Type | Default | Notes |\n| ---------------------- | -------------------------- | ------- | -------------------------------- |\n| `showGroupingControls` | `boolean` | `false` | Show the group-by toolbar. |\n| `groupBy` | `ReadonlyArray<string>` | `[]` | Column ids to group by, outermost first. Re-applies when the prop changes; ignored when `treeData` is set. |\n| `expanded` | `Record<string, boolean>` | `{}` | Which group / tree rows are open, keyed by row id. Seeds the state and re-applies on change. |\n\n## Callbacks\n\nAll callbacks are optional. None of them are required for the grid to\nfunction - they exist for parents that want to observe or override.\n\n### `onApiReady(api)`\n\nFires once after mount with the [`SvGridApi`](./SvGridApi.md) for\nimperative operations.\n\n### `onSortingChange(sorting)`\n\n```ts\nonSortingChange?: (sorting: Array<{ id: string; desc: boolean }>) => void\n```\n\nFires when the user clicks a sort header. With `externalSort={true}`,\nthis is your hook to re-fetch / re-order the source data.\n\n### `onFiltersChange(filters)`\n\n```ts\nonFiltersChange?: (filters: {\n global: string\n columns: Array<{\n id: string\n operator: 'contains' | 'equals' | 'startsWith' | 'greaterThan' | 'lessThan' | 'isBlank'\n value: string\n selectedValues?: Array<string>\n }>\n}) => void\n```\n\nFires when the global filter, any column operator filter, or any facet\nchecklist changes. With `externalFilter={true}`, this is your hook to\nre-fetch.\n\n### `onRowSelectionChange(selection, rows)`\n\n```ts\nonRowSelectionChange?: (\n selection: Record<string, boolean>,\n rows: TData[],\n) => void\n```\n\n`selection` is keyed by row index; `rows` is the materialised array of\nselected rows.\n\n### `onCellValueChange(event)`\n\n```ts\nonCellValueChange?: (event: {\n rowIndex: number\n columnId: string\n oldValue: unknown\n newValue: unknown\n row: TData\n}) => void\n```\n\nFires when an inline edit **commits**. This is the editor's commit path\nonly - the programmatic `api.setCellValue()` writes the value without\nfiring it. Use this for cascading recomputes (line totals, derived\ncolumns) and for sending edits to a server. See\n[Saving values](../help/editing/saving-values.md).\n\n### `onExpandedChange(expanded)`\n\n```ts\nonExpandedChange?: (expanded: Record<string, boolean>) => void\n```\n\nFires whenever the expanded set changes - a click on a group banner,\n`api.setRowExpanded()`, `api.expandAllGroups()` /\n`api.collapseAllGroups()`. Receives the full next map, so it can be\nwritten straight back into the `expanded` prop for a controlled setup\nwithout looping. Group row ids look like\n`group_department_Engineering`; tree rows use the engine row id.\n\n### `onActiveCellChange(cell)`\n\n```ts\nonActiveCellChange?: (cell: {\n rowIndex: number\n colIndex: number\n columnId: string\n}) => void\n```\n\nFires every time the active cell changes - click, keyboard move,\nTab, Page Up/Down. Toolbar / ribbon UIs use this to stay synced with\nthe grid's selection without polling.\n\n## See also\n\n- [`SvGridApi`](./SvGridApi.md) - the imperative API exposed via `onApiReady`\n- [`ColumnDef`](./ColumnDef.md) - column-definition shape\n- [features](./features.md) - the feature registry\n- [Enterprise extensions](./enterprise.md) - what `installEnterprise(api)` adds\n"
6397
+ "markdown": "# `<SvGrid>` reference\n\nThe render component. One `<SvGrid>` element per grid instance.\n\n```svelte\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n showPagination={true}\n pageSize={25}\n onApiReady={(api) => (gridApi = api)}\n/>\n```\n\n## Required props\n\n| Prop | Type | Notes |\n| ---------- | ------------------------------------------ | ---------------------------------------------------------------------- |\n| `data` | `ReadonlyArray<TData>` | The row source. Re-rendering on `data` reference change is automatic. |\n| `columns` | `Array<ColumnDef<TFeatures, TData>>` | See [ColumnDef reference](./ColumnDef.md). |\n| `features` | `TFeatures` | Build with `tableFeatures({...})`. See [features reference](./features.md). |\n\n## Data state\n\n| Prop | Type | Default | Notes |\n| -------------- | --------------------- | -------------------- | ----------------------------------------------------------- |\n| `loading` | `boolean` | `false` | Shows a built-in loading overlay. |\n| `error` | `string \\| null` | `null` | Shows an error banner inside the shell. |\n| `emptyMessage` | `string` | `\"No data\"` | Replaces the empty-state copy. |\n\n## Layout\n\n| Prop | Type | Default | Notes |\n| -------------------- | -------------------- | ----------- | ---------------------------------------------------------------------- |\n| `containerHeight` | `number \\| string` | `520` | Pixels (number) or any CSS height (string). Use `'100%'` inside a flex parent. |\n| `rowHeight` | `number \\| (i) => number` | `30` | In pixels. Drives virtualizer math when virtualization is on. Pass a function for per-row heights. Not a CSS token - the virtualizer needs a number, so it is written as an inline style per row. |\n| `columnWidth` | `number` | `140` | Default for columns without an explicit `width`. |\n| `fitColumns` | `boolean` | `false` | Scale columns proportionally to fill the viewport; residue absorbed in the last column. Shrinks down to 85% of natural widths; beyond that the user gets a horizontal scrollbar. |\n| `columnResize` | `boolean` | `false` | Drag a column edge to change its width, or focus the handle and use Left/Right (Shift = 1px). **Off by default**, so `width` is the width you asked for. Grid-wide, not per-column. `autosizeColumn` / `fitColumns` work either way. Handles load on demand. |\n| `rowResize` | `boolean` | `false` | Drag a row's bottom edge to change its height, or focus the grip and use Up/Down (Shift = 1px). The grid remembers the heights, so no `rowHeight` function is needed. **Turns on `showRowNumbers`**, since the grip lives in the row header; an explicit `showRowNumbers={false}` wins and the grip falls back to the row's first cell. Ignored under `autoRowHeight`. Grips load on demand. |\n| `showRowNumbers` | `boolean` | `false` | Leading 1-based row-number column. |\n| `rowNumberWidth` | `number` | `56` | Width (px) of the row-number column. Default fits up to \"99,999\"; bump it for six-figure row counts so the largest number stays fully visible at the bottom of a long scroll. |\n| `initialColumnPinning` | `{ left?, right? }`| - | Seed left/right pinning at mount. |\n\n## Icons\n\n| Prop | Type | Notes |\n| ------- | --------------------------------------- | ----- |\n| `icons` | `Partial<Record<GridIconName, Snippet>>` | Replace any of the glyphs the grid draws for its own chrome, keyed by name. Names you leave out keep their built-in glyph, so a partial map is all you ever need. Your markup is wrapped in the grid icon box, so it inherits the size of the glyph it replaced and the rotation an expander applies when it opens; use `currentColor` so it picks up hover / muted / accent states. See [Icons](../help/icons.md) for the full catalogue. |\n\n## Virtualization\n\n| Prop | Type | Default | Notes |\n| ---------------------- | --------- | ------- | -------------------------------------------------- |\n| `virtualization` | `boolean` | `true` | Row virtualization. |\n| `columnVirtualization` | `boolean` | `true` | Column virtualization. Disable for sticky-column pinning. |\n| `overscan` | `number` | `8` | Rows kept rendered above + below the viewport. |\n| `columnOverscan` | `number` | `3` | Columns always kept rendered ahead of a horizontal scroll (one behind). The columns move three at a time, so up to `columnOverscan + 2` are rendered ahead right after a move. |\n\n## Filter UI\n\n| Prop | Type | Default | Notes |\n| ------------------- | ------------------------------------------ | --------- | ---------------------------------------------------------------------- |\n| `filterMode` | `'menu' \\| 'row' \\| 'global' \\| 'none'` | `'menu'` | Umbrella prop. Overridden per-surface by the three below. |\n| `showGlobalFilter` | `boolean` | derived | Show the global search input. |\n| `showColumnFilters` | `boolean` | derived | Show the column-menu filter funnel. |\n| `showFilterRow` | `boolean` | derived | Show the per-column filter row under the header. |\n| `showFilterMenu` | `boolean` | derived | (Legacy alias for `showColumnFilters`.) |\n| `externalFilter` | `boolean` | `false` | Grid records filter UI state but does NOT filter rows. Pair with `onFiltersChange`. |\n\n## Selection\n\n| Prop | Type | Default | Notes |\n| -------------------- | ------------------------------------------ | ------- | ---------------------------------------------------- |\n| `selectionMode` | `'both' \\| 'row' \\| 'cell' \\| 'none'` | `'both'`| Umbrella prop. Overridden per-surface by the two below. |\n| `showRowSelection` | `boolean` | derived | Checkbox column. |\n| `enableCellSelection`| `boolean` | derived | Click-and-drag range selection. |\n\n## Editing\n\n| Prop | Type | Default | Notes |\n| --------------------- | --------- | ------- | ------------------------------------------------------ |\n| `enableInlineEditing` | `boolean` | `false` | Per-column `editorType` still required for a column to be editable. |\n| `enableRowSummaries` | `boolean` | `false` | Footer row aggregating every filtered row. Alias: `summary`. |\n\n## Sort\n\n| Prop | Type | Default | Notes |\n| -------------- | --------- | ------- | -------------------------------------------------------------- |\n| `externalSort` | `boolean` | `false` | Grid records sort state but does NOT re-order rows. Pair with `onSortingChange`. |\n\n## Pagination\n\n| Prop | Type | Default | Notes |\n| ---------------- | --------- | ------- | -------------------------------------------------------------- |\n| `showPagination` | `boolean` | `false` | Show the footer pager. |\n| `pageSize` | `number` | `10` | Initial page size. |\n\n## Grouping\n\n| Prop | Type | Default | Notes |\n| ---------------------- | -------------------------- | ------- | -------------------------------- |\n| `showGroupingControls` | `boolean` | `false` | Show the group-by toolbar. |\n| `groupBy` | `ReadonlyArray<string>` | `[]` | Column ids to group by, outermost first. Re-applies when the prop changes; ignored when `treeData` is set. |\n| `expanded` | `Record<string, boolean>` | `{}` | Which group / tree rows are open, keyed by row id. Seeds the state and re-applies on change. |\n\n## Callbacks\n\nAll callbacks are optional. None of them are required for the grid to\nfunction - they exist for parents that want to observe or override.\n\n### `onApiReady(api)`\n\nFires once after mount with the [`SvGridApi`](./SvGridApi.md) for\nimperative operations.\n\n### `onSortingChange(sorting)`\n\n```ts\nonSortingChange?: (sorting: Array<{ id: string; desc: boolean }>) => void\n```\n\nFires when the user clicks a sort header. With `externalSort={true}`,\nthis is your hook to re-fetch / re-order the source data.\n\n### `onFiltersChange(filters)`\n\n```ts\nonFiltersChange?: (filters: {\n global: string\n columns: Array<{\n id: string\n operator: 'contains' | 'equals' | 'startsWith' | 'greaterThan' | 'lessThan' | 'isBlank'\n value: string\n selectedValues?: Array<string>\n }>\n}) => void\n```\n\nFires when the global filter, any column operator filter, or any facet\nchecklist changes. With `externalFilter={true}`, this is your hook to\nre-fetch.\n\n### `onRowSelectionChange(selection, rows)`\n\n```ts\nonRowSelectionChange?: (\n selection: Record<string, boolean>,\n rows: TData[],\n) => void\n```\n\n`selection` is keyed by row index; `rows` is the materialised array of\nselected rows.\n\n### `onCellValueChange(event)`\n\n```ts\nonCellValueChange?: (event: {\n rowIndex: number\n columnId: string\n oldValue: unknown\n newValue: unknown\n row: TData\n}) => void\n```\n\nFires when an inline edit **commits**. This is the editor's commit path\nonly - the programmatic `api.setCellValue()` writes the value without\nfiring it. Use this for cascading recomputes (line totals, derived\ncolumns) and for sending edits to a server. See\n[Saving values](../help/editing/saving-values.md).\n\n### `onExpandedChange(expanded)`\n\n```ts\nonExpandedChange?: (expanded: Record<string, boolean>) => void\n```\n\nFires whenever the expanded set changes - a click on a group banner,\n`api.setRowExpanded()`, `api.expandAllGroups()` /\n`api.collapseAllGroups()`. Receives the full next map, so it can be\nwritten straight back into the `expanded` prop for a controlled setup\nwithout looping. Group row ids look like\n`group_department_Engineering`; tree rows use the engine row id.\n\n### `onActiveCellChange(cell)`\n\n```ts\nonActiveCellChange?: (cell: {\n rowIndex: number\n colIndex: number\n columnId: string\n}) => void\n```\n\nFires every time the active cell changes - click, keyboard move,\nTab, Page Up/Down. Toolbar / ribbon UIs use this to stay synced with\nthe grid's selection without polling.\n\n## See also\n\n- [`SvGridApi`](./SvGridApi.md) - the imperative API exposed via `onApiReady`\n- [`ColumnDef`](./ColumnDef.md) - column-definition shape\n- [features](./features.md) - the feature registry\n- [Enterprise extensions](./enterprise.md) - what `installEnterprise(api)` adds\n"
6383
6398
  },
6384
6399
  {
6385
6400
  "slug": "reference/SvGridApi",
@@ -7093,8 +7108,8 @@ export const apiReference = {
7093
7108
  ]
7094
7109
  };
7095
7110
  export const apiSurface = {
7096
- "gridVersion": "3.0.8",
7097
- "enterpriseVersion": "3.1.4",
7111
+ "gridVersion": "3.0.9",
7112
+ "enterpriseVersion": "3.1.5",
7098
7113
  "grid": {
7099
7114
  "values": [
7100
7115
  "ALL_FILTER_OPERATORS",
@@ -7351,6 +7366,7 @@ export const apiSurface = {
7351
7366
  "createTreeRowModel",
7352
7367
  "createTypeaheadBuffer",
7353
7368
  "createVirtualizer",
7369
+ "createWindowedData",
7354
7370
  "daysForView",
7355
7371
  "debounce",
7356
7372
  "decadeRange",
@@ -7474,6 +7490,7 @@ export const apiSurface = {
7474
7490
  "isSetOperator",
7475
7491
  "isTypeaheadKey",
7476
7492
  "isValuelessOperator",
7493
+ "isWindowedData",
7477
7494
  "isoWeek",
7478
7495
  "joinInTokens",
7479
7496
  "layoutDataLabels",
@@ -7679,6 +7696,7 @@ export const apiSurface = {
7679
7696
  "warnChartSpec",
7680
7697
  "weekdayOrder",
7681
7698
  "wheelWindow",
7699
+ "windowedSourceOf",
7682
7700
  "withTime",
7683
7701
  "withinWorking",
7684
7702
  "wma",
@@ -8228,6 +8246,7 @@ export const apiSurface = {
8228
8246
  "VirtualRange",
8229
8247
  "VirtualizerOptions",
8230
8248
  "VirtualizerState",
8249
+ "WindowedSource",
8231
8250
  "WorkingWindow",
8232
8251
  "ZoneParts"
8233
8252
  ],
@@ -10071,6 +10090,11 @@ export const apiSurface = {
10071
10090
  "optional": true,
10072
10091
  "type": "ReadonlyArray<{ rowIndex: number; colIndex: number; rowSpan: number; colSpan: number }>"
10073
10092
  },
10093
+ {
10094
+ "name": "rowLayout",
10095
+ "optional": true,
10096
+ "type": "\"flex\" | \"table\""
10097
+ },
10074
10098
  {
10075
10099
  "name": "pinnedBottomRows",
10076
10100
  "optional": true,