turbine-orm 0.36.1 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/dist/cjs/cli/index.js +53 -25
- package/dist/cjs/cli/studio-demo.js +310 -0
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.js +502 -127
- package/dist/cjs/errors.js +27 -0
- package/dist/cjs/nested-write.js +7 -7
- package/dist/cjs/query/batched-loader.js +4 -3
- package/dist/cjs/query/builder.js +9 -5
- package/dist/cjs/query/relations.js +7 -6
- package/dist/cjs/query/utils.js +13 -0
- package/dist/cjs/query/where-compile.js +2 -1
- package/dist/cjs/query/where.js +3 -3
- package/dist/cli/index.d.ts +3 -1
- package/dist/cli/index.js +53 -25
- package/dist/cli/studio-demo.d.ts +43 -0
- package/dist/cli/studio-demo.js +306 -0
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +58 -8
- package/dist/cli/studio.js +501 -127
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +26 -0
- package/dist/nested-write.js +8 -8
- package/dist/query/batched-loader.js +4 -3
- package/dist/query/builder.js +10 -6
- package/dist/query/relations.js +7 -6
- package/dist/query/utils.d.ts +10 -0
- package/dist/query/utils.js +12 -0
- package/dist/query/where-compile.js +2 -1
- package/dist/query/where.js +4 -4
- package/package.json +1 -1
|
@@ -3,4 +3,4 @@
|
|
|
3
3
|
// Regenerate via: node scripts/build-studio-ui.mjs
|
|
4
4
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
5
|
exports.STUDIO_HTML = void 0;
|
|
6
|
-
exports.STUDIO_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <meta name=\"color-scheme\" content=\"dark\" />\n <title>Turbine Studio</title>\n <!-- No external font fetch: Studio is loopback-only with a strict CSP\n (font-src 'self'), so we rely on the system font stack defined in\n --sans / --mono below. Keeps Studio fully offline + CSP-clean. -->\n <style>\n /* =====================================================================\n Design tokens\n ===================================================================== */\n :root {\n --bg: #0a0a0b;\n --bg-elev: #111113;\n --bg-hover: #1a1a1d;\n --bg-active: #22222a;\n --border: #26262b;\n --border-strong: #3a3a42;\n --text: #e6e6ea;\n --text-dim: #8a8a93;\n --text-muted: #5a5a63;\n --accent: #60a5fa;\n --accent-hover: #93c5fd;\n --accent-dim: rgba(96, 165, 250, 0.1);\n --green: #4ade80;\n --orange: #fb923c;\n --red: #f87171;\n --yellow: #facc15;\n --purple: #a78bfa;\n --mono: \"Geist Mono\", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;\n --sans: Inter, system-ui, -apple-system, \"Segoe UI\", sans-serif;\n --ease: cubic-bezier(0.4, 0, 0.2, 1);\n --t: 150ms var(--ease);\n --radius: 6px;\n --radius-lg: 10px;\n --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.6), 0 2px 6px rgba(0, 0, 0, 0.4);\n --header-h: 48px;\n --sidebar-w: 280px;\n }\n\n /* =====================================================================\n Write mode (opt-in) + PII\n ===================================================================== */\n .write-banner {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 6px 14px;\n background: rgba(248, 113, 113, 0.14);\n border-bottom: 1px solid var(--red);\n color: var(--red);\n font-size: 12px;\n letter-spacing: 0.04em;\n flex: 0 0 auto;\n }\n .write-banner strong {\n font-weight: 700;\n }\n .write-banner span:last-child {\n color: var(--text-dim);\n }\n .write-banner-dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n background: var(--red);\n box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.2);\n }\n .pii-banner {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 6px 14px;\n background: rgba(250, 204, 21, 0.12);\n border-bottom: 1px solid rgba(250, 204, 21, 0.5);\n color: var(--yellow);\n font-size: 12px;\n letter-spacing: 0.04em;\n flex: 0 0 auto;\n }\n .pii-banner strong {\n font-weight: 700;\n }\n .pii-banner span:last-child {\n color: var(--text-dim);\n }\n .badge.pii {\n background: rgba(250, 204, 21, 0.14);\n color: var(--yellow);\n border: 1px solid rgba(250, 204, 21, 0.3);\n }\n .row-actions {\n display: flex;\n gap: 4px;\n white-space: nowrap;\n }\n .row-action-btn {\n background: transparent;\n border: 1px solid var(--border);\n color: var(--text-dim);\n border-radius: var(--radius);\n padding: 2px 8px;\n font-size: 11px;\n cursor: pointer;\n transition: var(--t);\n }\n .row-action-btn:hover {\n background: var(--bg-hover);\n color: var(--text);\n }\n .row-action-btn.danger:hover {\n border-color: var(--red);\n color: var(--red);\n }\n .row-editor-hint {\n color: var(--text-dim);\n font-size: 12px;\n margin-bottom: 12px;\n }\n .row-editor-fields {\n display: flex;\n flex-direction: column;\n gap: 10px;\n max-height: 52vh;\n overflow-y: auto;\n }\n .row-editor-field {\n display: flex;\n flex-direction: column;\n gap: 4px;\n }\n .row-editor-field label {\n font-size: 11px;\n color: var(--text-dim);\n font-family: var(--mono);\n }\n .row-editor-field label .pkflag {\n color: var(--accent);\n }\n .row-editor-field label .piiflag {\n color: var(--yellow);\n }\n .row-editor-field input {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n color: var(--text);\n padding: 6px 8px;\n font-family: var(--mono);\n font-size: 12px;\n }\n .row-editor-field input:disabled {\n opacity: 0.55;\n }\n .row-editor-input-row {\n display: flex;\n align-items: stretch;\n gap: 6px;\n }\n .row-editor-input-row input {\n flex: 1 1 auto;\n min-width: 0;\n }\n .row-editor-field input.null-set {\n opacity: 0.5;\n font-style: italic;\n color: var(--text-dim);\n }\n .null-toggle {\n flex: 0 0 auto;\n background: transparent;\n border: 1px solid var(--border);\n color: var(--text-dim);\n border-radius: var(--radius);\n padding: 2px 10px;\n font-family: var(--mono);\n font-size: 11px;\n letter-spacing: 0.04em;\n cursor: pointer;\n transition: var(--t);\n }\n .null-toggle:hover {\n background: var(--bg-hover);\n color: var(--text);\n }\n .null-toggle.active {\n background: var(--accent-dim);\n border-color: var(--accent);\n color: var(--accent);\n }\n .row-editor-error {\n margin-top: 12px;\n padding: 8px 10px;\n background: rgba(248, 113, 113, 0.1);\n border: 1px solid var(--red);\n border-radius: var(--radius);\n color: var(--red);\n font-size: 12px;\n white-space: pre-wrap;\n }\n\n /* =====================================================================\n Reset + base\n ===================================================================== */\n *,\n *::before,\n *::after {\n box-sizing: border-box;\n }\n html,\n body {\n margin: 0;\n padding: 0;\n height: 100%;\n background: var(--bg);\n color: var(--text);\n font-family: var(--sans);\n font-size: 13px;\n line-height: 1.5;\n -webkit-font-smoothing: antialiased;\n text-rendering: optimizeLegibility;\n }\n body {\n overflow: hidden;\n }\n button {\n font-family: inherit;\n font-size: inherit;\n color: inherit;\n background: none;\n border: none;\n cursor: pointer;\n padding: 0;\n }\n input,\n textarea,\n select {\n font-family: inherit;\n font-size: inherit;\n color: inherit;\n }\n ::selection {\n background: var(--accent-dim);\n color: var(--text);\n }\n ::-webkit-scrollbar {\n width: 10px;\n height: 10px;\n }\n ::-webkit-scrollbar-track {\n background: transparent;\n }\n ::-webkit-scrollbar-thumb {\n background: #2a2a30;\n border-radius: 10px;\n border: 2px solid var(--bg);\n }\n ::-webkit-scrollbar-thumb:hover {\n background: #3a3a42;\n }\n :focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n border-radius: 3px;\n }\n a {\n color: var(--accent);\n text-decoration: none;\n }\n a:hover {\n color: var(--accent-hover);\n }\n\n /* =====================================================================\n App shell\n ===================================================================== */\n .app {\n display: grid;\n grid-template-rows: var(--header-h) 1fr;\n height: 100vh;\n width: 100vw;\n }\n\n /* Header */\n .header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 0 16px;\n border-bottom: 1px solid var(--border);\n background: var(--bg-elev);\n height: var(--header-h);\n user-select: none;\n }\n .brand {\n display: flex;\n align-items: center;\n gap: 10px;\n font-family: var(--mono);\n font-size: 13px;\n font-weight: 600;\n letter-spacing: -0.01em;\n }\n .brand-mark {\n width: 18px;\n height: 18px;\n border-radius: 4px;\n background: linear-gradient(135deg, var(--accent) 0%, var(--purple) 100%);\n position: relative;\n }\n .brand-mark::after {\n content: \"\";\n position: absolute;\n inset: 4px;\n border-radius: 2px;\n background: var(--bg-elev);\n }\n .brand-dot {\n color: var(--text-muted);\n }\n .brand-sub {\n color: var(--text-dim);\n font-weight: 400;\n }\n .header-right {\n display: flex;\n align-items: center;\n gap: 14px;\n font-size: 12px;\n color: var(--text-dim);\n }\n .header-meta {\n font-family: var(--mono);\n font-size: 11.5px;\n }\n .header-meta .sep {\n color: var(--text-muted);\n margin: 0 6px;\n }\n .conn {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n color: var(--text-dim);\n }\n .conn-dot {\n width: 7px;\n height: 7px;\n border-radius: 50%;\n background: var(--text-muted);\n transition: background var(--t), box-shadow var(--t);\n }\n .conn-dot.ok {\n background: var(--green);\n box-shadow: 0 0 0 3px rgba(74, 222, 128, 0.15);\n }\n .conn-dot.err {\n background: var(--red);\n box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.15);\n }\n .kbd {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n min-width: 18px;\n height: 18px;\n padding: 0 5px;\n border: 1px solid var(--border-strong);\n border-bottom-width: 2px;\n border-radius: 4px;\n background: var(--bg-elev);\n font-family: var(--mono);\n font-size: 10.5px;\n color: var(--text-dim);\n }\n .header-cmd {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 4px 8px 4px 10px;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: var(--bg);\n color: var(--text-dim);\n font-size: 12px;\n transition: border-color var(--t), color var(--t);\n }\n .header-cmd:hover {\n border-color: var(--border-strong);\n color: var(--text);\n }\n .icon-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n border-radius: var(--radius);\n color: var(--text-dim);\n transition: background var(--t), color var(--t);\n }\n .icon-btn:hover {\n background: var(--bg-hover);\n color: var(--text);\n }\n .hamburger {\n display: none;\n }\n\n /* Body layout */\n .body {\n display: grid;\n grid-template-columns: var(--sidebar-w) 1fr;\n min-height: 0;\n overflow: hidden;\n position: relative;\n }\n\n /* =====================================================================\n Sidebar\n ===================================================================== */\n .sidebar {\n display: flex;\n flex-direction: column;\n border-right: 1px solid var(--border);\n background: var(--bg-elev);\n min-width: 0;\n position: relative;\n }\n .sidebar-header {\n padding: 14px 16px 10px;\n border-bottom: 1px solid var(--border);\n }\n .sidebar-title {\n font-family: var(--mono);\n font-size: 11px;\n font-weight: 600;\n color: var(--text);\n letter-spacing: 0.02em;\n text-transform: lowercase;\n display: flex;\n align-items: center;\n gap: 6px;\n }\n .sidebar-meta {\n font-family: var(--mono);\n font-size: 10.5px;\n color: var(--text-muted);\n margin-top: 4px;\n letter-spacing: 0.02em;\n }\n .sidebar-search {\n margin: 10px 12px 4px;\n position: relative;\n }\n .sidebar-search input {\n width: 100%;\n padding: 7px 10px 7px 28px;\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n color: var(--text);\n font-size: 12px;\n transition: border-color var(--t), background var(--t);\n }\n .sidebar-search input:focus {\n outline: none;\n border-color: var(--border-strong);\n }\n .sidebar-search input::placeholder {\n color: var(--text-muted);\n }\n .sidebar-search-icon {\n position: absolute;\n left: 9px;\n top: 50%;\n transform: translateY(-50%);\n color: var(--text-muted);\n width: 13px;\n height: 13px;\n pointer-events: none;\n }\n .sidebar-scroll {\n flex: 1;\n overflow-y: auto;\n padding: 8px 0 16px;\n }\n .sidebar-group {\n padding: 4px 0;\n }\n .sidebar-group-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 16px 4px;\n font-family: var(--mono);\n font-size: 10px;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n cursor: pointer;\n user-select: none;\n transition: color var(--t);\n }\n .sidebar-group-header:hover {\n color: var(--text-dim);\n }\n .sidebar-group-header .chev {\n transition: transform var(--t);\n }\n .sidebar-group.collapsed .chev {\n transform: rotate(-90deg);\n }\n .sidebar-group.collapsed .sidebar-group-body {\n display: none;\n }\n .sidebar-group-body {\n display: flex;\n flex-direction: column;\n }\n .sidebar-item {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 5px 16px 5px 22px;\n font-size: 12.5px;\n font-family: var(--mono);\n color: var(--text-dim);\n cursor: pointer;\n transition: background var(--t), color var(--t);\n user-select: none;\n min-width: 0;\n }\n .sidebar-item-name {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n min-width: 0;\n }\n .sidebar-item-count {\n margin-left: 8px;\n font-size: 10.5px;\n color: var(--text-muted);\n flex-shrink: 0;\n }\n .sidebar-item:hover {\n background: var(--bg-hover);\n color: var(--text);\n }\n .sidebar-item.active {\n background: var(--bg-active);\n color: var(--text);\n border-left: 2px solid var(--accent);\n padding-left: 20px;\n }\n .sidebar-item.active .sidebar-item-count {\n color: var(--text-dim);\n }\n .sidebar-empty {\n padding: 6px 22px;\n font-size: 11.5px;\n color: var(--text-muted);\n font-style: italic;\n }\n\n .saved-group {\n margin-top: 6px;\n }\n .saved-table-header {\n padding: 6px 16px 3px 16px;\n font-family: var(--mono);\n font-size: 10px;\n color: var(--text-muted);\n text-transform: lowercase;\n letter-spacing: 0.02em;\n }\n .saved-query {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 4px 16px 4px 22px;\n font-size: 12px;\n color: var(--text-dim);\n cursor: pointer;\n transition: background var(--t), color var(--t);\n }\n .saved-query:hover {\n background: var(--bg-hover);\n color: var(--text);\n }\n .saved-query .saved-kind {\n font-family: var(--mono);\n font-size: 9px;\n padding: 1px 5px;\n border-radius: 3px;\n border: 1px solid var(--border);\n color: var(--text-muted);\n text-transform: uppercase;\n }\n .saved-query-name {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n flex: 1;\n }\n .saved-query-del {\n opacity: 0;\n transition: opacity var(--t);\n color: var(--text-muted);\n padding: 0 2px;\n }\n .saved-query:hover .saved-query-del {\n opacity: 1;\n }\n .saved-query-del:hover {\n color: var(--red);\n }\n\n /* Sidebar resizer */\n .sidebar-resizer {\n position: absolute;\n top: 0;\n right: -3px;\n width: 6px;\n height: 100%;\n cursor: col-resize;\n z-index: 10;\n user-select: none;\n }\n .sidebar-resizer:hover::after,\n .sidebar-resizer.dragging::after {\n content: \"\";\n position: absolute;\n right: 2px;\n top: 0;\n width: 2px;\n height: 100%;\n background: var(--accent);\n }\n\n /* =====================================================================\n Main area\n ===================================================================== */\n .main {\n display: flex;\n flex-direction: column;\n min-width: 0;\n min-height: 0;\n overflow: hidden;\n }\n .tabs {\n display: flex;\n align-items: center;\n gap: 2px;\n padding: 0 16px;\n border-bottom: 1px solid var(--border);\n background: var(--bg-elev);\n height: 38px;\n flex-shrink: 0;\n }\n .tab {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n padding: 0 12px;\n height: 38px;\n font-size: 12.5px;\n color: var(--text-dim);\n border-bottom: 2px solid transparent;\n margin-bottom: -1px;\n transition: color var(--t), border-color var(--t);\n user-select: none;\n }\n .tab:hover {\n color: var(--text);\n }\n .tab.active {\n color: var(--text);\n border-bottom-color: var(--accent);\n }\n .tab-icon {\n width: 13px;\n height: 13px;\n opacity: 0.7;\n }\n .tab.active .tab-icon {\n opacity: 1;\n }\n\n .pane {\n flex: 1;\n min-height: 0;\n display: none;\n flex-direction: column;\n overflow: hidden;\n }\n .pane.active {\n display: flex;\n }\n .pane-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 16px;\n padding: 14px 20px;\n border-bottom: 1px solid var(--border);\n flex-shrink: 0;\n }\n .pane-title {\n display: flex;\n align-items: baseline;\n gap: 10px;\n min-width: 0;\n }\n .pane-title h1 {\n margin: 0;\n font-size: 18px;\n font-weight: 600;\n font-family: var(--mono);\n letter-spacing: -0.01em;\n color: var(--text);\n }\n .pane-title .muted {\n font-size: 12px;\n color: var(--text-muted);\n font-family: var(--mono);\n }\n .toolbar {\n display: flex;\n align-items: center;\n gap: 8px;\n }\n .search-input {\n position: relative;\n }\n .search-input input {\n padding: 6px 10px 6px 28px;\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n color: var(--text);\n font-size: 12px;\n width: 220px;\n transition: border-color var(--t), width var(--t);\n font-family: var(--mono);\n }\n .search-input input:focus {\n outline: none;\n border-color: var(--border-strong);\n width: 280px;\n }\n .search-input-icon {\n position: absolute;\n left: 9px;\n top: 50%;\n transform: translateY(-50%);\n color: var(--text-muted);\n width: 13px;\n height: 13px;\n pointer-events: none;\n }\n\n /* Buttons */\n .btn {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 6px 12px;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: var(--bg);\n color: var(--text);\n font-size: 12px;\n transition: all var(--t);\n white-space: nowrap;\n }\n .btn:hover {\n background: var(--bg-hover);\n border-color: var(--border-strong);\n }\n .btn:disabled {\n opacity: 0.5;\n cursor: not-allowed;\n }\n .btn-primary {\n background: var(--accent);\n border-color: var(--accent);\n color: #0a0a0b;\n font-weight: 500;\n }\n .btn-primary:hover:not(:disabled) {\n background: var(--accent-hover);\n border-color: var(--accent-hover);\n }\n .btn-ghost {\n border-color: transparent;\n background: transparent;\n color: var(--text-dim);\n }\n .btn-ghost:hover {\n background: var(--bg-hover);\n color: var(--text);\n }\n .btn-sm {\n padding: 4px 8px;\n font-size: 11px;\n }\n\n /* =====================================================================\n Data table\n ===================================================================== */\n .data-wrap {\n flex: 1;\n min-height: 0;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n .data-scroll {\n flex: 1;\n overflow: auto;\n min-height: 0;\n position: relative;\n }\n table.data {\n border-collapse: separate;\n border-spacing: 0;\n width: 100%;\n font-family: var(--mono);\n font-size: 12px;\n }\n table.data thead th {\n position: sticky;\n top: 0;\n background: var(--bg-elev);\n border-bottom: 1px solid var(--border);\n padding: 8px 14px;\n text-align: left;\n font-weight: 500;\n color: var(--text-dim);\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n white-space: nowrap;\n z-index: 2;\n user-select: none;\n }\n table.data thead th.sortable {\n cursor: pointer;\n transition: color var(--t), background var(--t);\n }\n table.data thead th.sortable:hover {\n color: var(--text);\n background: var(--bg-hover);\n }\n table.data thead th .sort-ind {\n color: var(--accent);\n margin-left: 4px;\n font-size: 10px;\n }\n table.data tbody td {\n padding: 7px 14px;\n border-bottom: 1px solid var(--border);\n color: var(--text);\n max-width: 360px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n vertical-align: middle;\n }\n table.data tbody tr {\n transition: background var(--t);\n }\n table.data tbody tr:hover {\n background: var(--bg-hover);\n }\n .cell-null {\n color: var(--text-muted);\n font-style: italic;\n }\n .cell-json {\n color: var(--purple);\n }\n .cell-json .expand-btn {\n margin-left: 6px;\n font-size: 10px;\n color: var(--text-muted);\n padding: 1px 4px;\n border: 1px solid var(--border);\n border-radius: 3px;\n transition: all var(--t);\n }\n .cell-json .expand-btn:hover {\n color: var(--accent);\n border-color: var(--accent);\n }\n .cell-number {\n color: var(--green);\n }\n .cell-bool {\n color: var(--orange);\n }\n .data-footer {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 10px 20px;\n border-top: 1px solid var(--border);\n background: var(--bg-elev);\n font-size: 11.5px;\n color: var(--text-dim);\n flex-shrink: 0;\n font-family: var(--mono);\n }\n .pagination {\n display: flex;\n align-items: center;\n gap: 8px;\n }\n\n /* States */\n .empty,\n .error-box,\n .loading {\n padding: 40px 24px;\n text-align: center;\n color: var(--text-dim);\n font-size: 13px;\n }\n .empty-icon {\n margin: 0 auto 10px;\n width: 28px;\n height: 28px;\n color: var(--text-muted);\n }\n .error-box {\n margin: 16px;\n padding: 14px 18px;\n border: 1px solid rgba(248, 113, 113, 0.35);\n background: rgba(248, 113, 113, 0.06);\n border-radius: var(--radius);\n color: var(--red);\n text-align: left;\n font-family: var(--mono);\n font-size: 12px;\n white-space: pre-wrap;\n }\n .error-box-title {\n font-weight: 600;\n margin-bottom: 4px;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n font-size: 10.5px;\n }\n\n /* Skeleton */\n @keyframes shimmer {\n 0% {\n background-position: -200% 0;\n }\n 100% {\n background-position: 200% 0;\n }\n }\n .skeleton-row {\n display: flex;\n gap: 16px;\n padding: 10px 14px;\n border-bottom: 1px solid var(--border);\n }\n .skeleton-cell {\n height: 10px;\n border-radius: 3px;\n background: linear-gradient(\n 90deg,\n #1a1a1d 0%,\n #22222a 50%,\n #1a1a1d 100%\n );\n background-size: 200% 100%;\n animation: shimmer 1.4s linear infinite;\n flex: 1;\n }\n\n /* =====================================================================\n Schema pane\n ===================================================================== */\n .schema-scroll {\n flex: 1;\n overflow: auto;\n padding: 24px 32px 40px;\n }\n .schema-section {\n margin-bottom: 32px;\n }\n .schema-section h2 {\n font-size: 11px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-dim);\n margin: 0 0 12px;\n font-family: var(--mono);\n }\n .col-table {\n width: 100%;\n border-collapse: separate;\n border-spacing: 0;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n overflow: hidden;\n background: var(--bg-elev);\n }\n .col-table th,\n .col-table td {\n padding: 9px 14px;\n text-align: left;\n font-family: var(--mono);\n font-size: 12px;\n border-bottom: 1px solid var(--border);\n }\n .col-table th {\n font-size: 10.5px;\n font-weight: 500;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n color: var(--text-muted);\n background: var(--bg);\n }\n .col-table tr:last-child td {\n border-bottom: none;\n }\n .col-name {\n color: var(--text);\n font-weight: 500;\n }\n .col-name.pk {\n color: var(--orange);\n }\n .col-ts {\n color: var(--green);\n }\n .col-pg {\n color: var(--text-dim);\n }\n .col-rel-name {\n color: var(--accent);\n }\n .badge {\n display: inline-block;\n padding: 1px 6px;\n border-radius: 3px;\n font-size: 9.5px;\n font-weight: 500;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n margin-right: 4px;\n font-family: var(--mono);\n border: 1px solid transparent;\n }\n .badge.pk {\n background: rgba(251, 146, 60, 0.12);\n color: var(--orange);\n border-color: rgba(251, 146, 60, 0.25);\n }\n .badge.nn {\n background: rgba(248, 113, 113, 0.1);\n color: var(--red);\n border-color: rgba(248, 113, 113, 0.2);\n }\n .badge.def {\n background: rgba(167, 139, 250, 0.1);\n color: var(--purple);\n border-color: rgba(167, 139, 250, 0.2);\n }\n .badge.nullable {\n background: var(--bg);\n color: var(--text-muted);\n border-color: var(--border);\n }\n .badge.rel-type {\n background: var(--accent-dim);\n color: var(--accent);\n border-color: rgba(96, 165, 250, 0.25);\n }\n .schema-details {\n margin-top: 18px;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: var(--bg-elev);\n overflow: hidden;\n }\n .schema-details summary {\n padding: 10px 14px;\n font-family: var(--mono);\n font-size: 11px;\n color: var(--text-dim);\n cursor: pointer;\n user-select: none;\n list-style: none;\n display: flex;\n align-items: center;\n gap: 8px;\n }\n .schema-details summary::-webkit-details-marker {\n display: none;\n }\n .schema-details summary::before {\n content: \"›\";\n color: var(--text-muted);\n transition: transform var(--t);\n display: inline-block;\n }\n .schema-details[open] summary::before {\n transform: rotate(90deg);\n }\n .schema-details pre {\n margin: 0;\n padding: 14px 16px;\n font-family: var(--mono);\n font-size: 11.5px;\n line-height: 1.6;\n color: var(--text-dim);\n overflow-x: auto;\n border-top: 1px solid var(--border);\n background: var(--bg);\n }\n .sql-kw {\n color: var(--accent);\n font-weight: 500;\n }\n .sql-type {\n color: var(--green);\n }\n .sql-ident {\n color: var(--text);\n }\n .sql-str {\n color: var(--yellow);\n }\n\n /* =====================================================================\n Builder pane\n ===================================================================== */\n .builder-wrap {\n flex: 1;\n min-height: 0;\n display: grid;\n grid-template-columns: 1fr 1fr;\n min-width: 0;\n }\n .builder-left {\n border-right: 1px solid var(--border);\n overflow-y: auto;\n padding: 20px 24px;\n }\n .builder-right {\n display: flex;\n flex-direction: column;\n min-height: 0;\n background: var(--bg);\n min-width: 0;\n }\n .builder-preview {\n padding: 18px 22px;\n font-family: var(--mono);\n font-size: 12.5px;\n line-height: 1.65;\n color: var(--text);\n white-space: pre;\n overflow: auto;\n flex: 1;\n min-height: 0;\n border-bottom: 1px solid var(--border);\n }\n .tok-kw {\n color: var(--accent);\n }\n .tok-fn {\n color: var(--yellow);\n }\n .tok-str {\n color: var(--green);\n }\n .tok-num {\n color: var(--orange);\n }\n .tok-key {\n color: var(--purple);\n }\n .tok-punc {\n color: var(--text-dim);\n }\n .tok-comment {\n color: var(--text-muted);\n font-style: italic;\n }\n .builder-results {\n flex: 1;\n min-height: 200px;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n .builder-results-header {\n padding: 10px 22px;\n font-family: var(--mono);\n font-size: 11px;\n color: var(--text-dim);\n border-bottom: 1px solid var(--border);\n background: var(--bg-elev);\n text-transform: uppercase;\n letter-spacing: 0.04em;\n flex-shrink: 0;\n }\n\n .builder-section {\n margin-bottom: 18px;\n }\n .builder-section-title {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding-bottom: 8px;\n margin-bottom: 10px;\n border-bottom: 1px dashed var(--border);\n }\n .builder-section-title h3 {\n margin: 0;\n font-size: 11px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.06em;\n color: var(--text-dim);\n font-family: var(--mono);\n }\n .builder-field {\n margin-bottom: 10px;\n }\n .builder-field label {\n display: block;\n font-size: 11px;\n color: var(--text-muted);\n margin-bottom: 4px;\n font-family: var(--mono);\n text-transform: uppercase;\n letter-spacing: 0.04em;\n }\n .builder-input,\n .builder-select {\n width: 100%;\n padding: 6px 10px;\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n color: var(--text);\n font-size: 12px;\n font-family: var(--mono);\n transition: border-color var(--t);\n }\n .builder-input:focus,\n .builder-select:focus {\n outline: none;\n border-color: var(--border-strong);\n }\n .builder-input.invalid,\n .builder-select.invalid {\n border-color: rgba(248, 113, 113, 0.5);\n }\n .field-hint {\n font-size: 10.5px;\n color: var(--red);\n margin-top: 3px;\n font-family: var(--mono);\n }\n .where-clauses {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n .where-clause {\n display: grid;\n grid-template-columns: 1fr 1fr 1.2fr auto;\n gap: 6px;\n align-items: center;\n }\n .where-clause .btn-ghost {\n padding: 4px 6px;\n }\n .clause-row {\n display: flex;\n gap: 6px;\n align-items: center;\n }\n .combinator-pick {\n display: flex;\n gap: 0;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n overflow: hidden;\n background: var(--bg);\n }\n .combinator-pick button {\n padding: 4px 10px;\n font-family: var(--mono);\n font-size: 10.5px;\n color: var(--text-dim);\n border-right: 1px solid var(--border);\n transition: background var(--t), color var(--t);\n }\n .combinator-pick button:last-child {\n border-right: none;\n }\n .combinator-pick button.active {\n background: var(--accent-dim);\n color: var(--accent);\n }\n .combinator-pick button:hover:not(.active) {\n background: var(--bg-hover);\n }\n .with-rels {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n .with-rel {\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: var(--bg);\n }\n .with-rel-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 7px 10px;\n cursor: pointer;\n user-select: none;\n gap: 8px;\n }\n .with-rel-header:hover {\n background: var(--bg-hover);\n }\n .with-rel-name {\n font-family: var(--mono);\n font-size: 11.5px;\n color: var(--accent);\n }\n .with-rel-meta {\n font-family: var(--mono);\n font-size: 10px;\n color: var(--text-muted);\n }\n .with-rel-body {\n padding: 10px;\n border-top: 1px solid var(--border);\n display: none;\n }\n .with-rel.expanded .with-rel-body {\n display: block;\n }\n .with-rel .chev {\n transition: transform var(--t);\n color: var(--text-muted);\n }\n .with-rel.expanded .chev {\n transform: rotate(90deg);\n }\n .chip-row {\n display: flex;\n gap: 6px;\n flex-wrap: wrap;\n }\n .chip {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n padding: 3px 8px;\n border: 1px solid var(--border);\n border-radius: 20px;\n background: var(--bg);\n font-family: var(--mono);\n font-size: 10.5px;\n color: var(--text-dim);\n cursor: pointer;\n transition: all var(--t);\n }\n .chip:hover {\n border-color: var(--border-strong);\n color: var(--text);\n }\n .chip.active {\n background: var(--accent-dim);\n border-color: rgba(96, 165, 250, 0.35);\n color: var(--accent);\n }\n\n /* =====================================================================\n Modal / dialog\n ===================================================================== */\n .modal-backdrop {\n position: fixed;\n inset: 0;\n background: rgba(0, 0, 0, 0.65);\n display: none;\n align-items: center;\n justify-content: center;\n z-index: 100;\n backdrop-filter: blur(4px);\n }\n .modal-backdrop.open {\n display: flex;\n animation: fade-in 150ms var(--ease);\n }\n @keyframes fade-in {\n from {\n opacity: 0;\n }\n to {\n opacity: 1;\n }\n }\n @keyframes slide-up {\n from {\n opacity: 0;\n transform: translateY(8px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n }\n .modal {\n background: var(--bg-elev);\n border: 1px solid var(--border);\n border-radius: var(--radius-lg);\n box-shadow: var(--shadow-lg);\n min-width: 400px;\n max-width: 90vw;\n max-height: 85vh;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n animation: slide-up 180ms var(--ease);\n }\n .modal-header {\n padding: 14px 18px;\n border-bottom: 1px solid var(--border);\n display: flex;\n align-items: center;\n justify-content: space-between;\n }\n .modal-title {\n font-size: 13px;\n font-weight: 600;\n color: var(--text);\n font-family: var(--mono);\n }\n .modal-body {\n padding: 16px 18px;\n overflow-y: auto;\n flex: 1;\n min-height: 0;\n }\n .modal-footer {\n padding: 12px 18px;\n border-top: 1px solid var(--border);\n display: flex;\n justify-content: flex-end;\n gap: 8px;\n }\n .modal-field {\n margin-bottom: 12px;\n }\n .modal-field label {\n display: block;\n font-size: 11px;\n color: var(--text-dim);\n margin-bottom: 5px;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n font-family: var(--mono);\n }\n .modal-field input,\n .modal-field select {\n width: 100%;\n padding: 7px 10px;\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n color: var(--text);\n font-family: var(--mono);\n font-size: 12px;\n }\n .modal-field input:focus,\n .modal-field select:focus {\n outline: none;\n border-color: var(--border-strong);\n }\n .json-view {\n font-family: var(--mono);\n font-size: 12px;\n color: var(--text);\n white-space: pre-wrap;\n word-break: break-word;\n }\n\n /* =====================================================================\n Command palette\n ===================================================================== */\n .palette {\n width: 560px;\n max-width: 92vw;\n }\n .palette-input {\n width: 100%;\n padding: 14px 18px;\n background: transparent;\n border: none;\n border-bottom: 1px solid var(--border);\n color: var(--text);\n font-size: 14px;\n font-family: var(--sans);\n }\n .palette-input:focus {\n outline: none;\n }\n .palette-input::placeholder {\n color: var(--text-muted);\n }\n .palette-list {\n max-height: 50vh;\n overflow-y: auto;\n padding: 6px 0;\n }\n .palette-section {\n padding: 8px 16px 4px;\n font-size: 10px;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n font-family: var(--mono);\n }\n .palette-item {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 16px;\n cursor: pointer;\n transition: background var(--t);\n font-size: 13px;\n }\n .palette-item:hover,\n .palette-item.selected {\n background: var(--bg-hover);\n }\n .palette-item .pi-name {\n display: flex;\n align-items: center;\n gap: 10px;\n }\n .palette-item .pi-icon {\n color: var(--text-dim);\n width: 14px;\n height: 14px;\n }\n .palette-item .pi-kbd {\n display: flex;\n gap: 3px;\n }\n\n /* =====================================================================\n Responsive\n ===================================================================== */\n @media (max-width: 1024px) {\n .body {\n grid-template-columns: 1fr;\n }\n .sidebar {\n position: fixed;\n top: var(--header-h);\n left: 0;\n bottom: 0;\n width: 280px;\n transform: translateX(-100%);\n transition: transform 200ms var(--ease);\n z-index: 50;\n }\n .sidebar.open {\n transform: translateX(0);\n }\n .hamburger {\n display: inline-flex;\n }\n .sidebar-resizer {\n display: none;\n }\n .search-input input {\n width: 160px;\n }\n .search-input input:focus {\n width: 200px;\n }\n }\n\n .toast-wrap {\n position: fixed;\n bottom: 24px;\n right: 24px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n z-index: 200;\n }\n .toast {\n padding: 10px 14px;\n background: var(--bg-elev);\n border: 1px solid var(--border-strong);\n border-radius: var(--radius);\n font-size: 12px;\n color: var(--text);\n box-shadow: var(--shadow-lg);\n animation: slide-up 200ms var(--ease);\n max-width: 320px;\n }\n .toast.ok {\n border-left: 3px solid var(--green);\n }\n .toast.err {\n border-left: 3px solid var(--red);\n }\n </style>\n</head>\n<body>\n <div class=\"app\" id=\"app\">\n <!-- Write-mode banner (shown only when the server runs with --write) -->\n <div class=\"write-banner\" id=\"writeBanner\" role=\"alert\" hidden>\n <span class=\"write-banner-dot\" aria-hidden=\"true\"></span>\n <strong>WRITE MODE</strong>\n <span>changes are committed directly to your database</span>\n </div>\n <!-- PII banner (shown only when the server runs with --show-pii) -->\n <div class=\"pii-banner\" id=\"piiBanner\" role=\"alert\" hidden>\n <strong>PII SHOWN</strong>\n <span>PII-tagged column values are displayed unredacted (--show-pii)</span>\n </div>\n <!-- Header -->\n <header class=\"header\">\n <div class=\"brand\">\n <button class=\"icon-btn hamburger\" id=\"hamburger\" aria-label=\"Toggle sidebar\">\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <path d=\"M3 6h18M3 12h18M3 18h18\" />\n </svg>\n </button>\n <span class=\"brand-mark\" aria-hidden=\"true\"></span>\n <span>turbine</span>\n <span class=\"brand-dot\">·</span>\n <span class=\"brand-sub\">studio</span>\n </div>\n <div class=\"header-right\">\n <button class=\"header-cmd\" id=\"openPalette\" aria-label=\"Open command palette\">\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <circle cx=\"11\" cy=\"11\" r=\"7\" />\n <path d=\"m21 21-4.3-4.3\" />\n </svg>\n <span>Jump to…</span>\n <span class=\"kbd\">⌘K</span>\n </button>\n <span class=\"header-meta\" id=\"headerMeta\">—</span>\n <div class=\"conn\" id=\"conn\">\n <span class=\"conn-dot\" id=\"connDot\"></span>\n <span id=\"connLabel\">connecting</span>\n </div>\n </div>\n </header>\n\n <div class=\"body\">\n <!-- Sidebar -->\n <aside class=\"sidebar\" id=\"sidebar\" aria-label=\"Schema sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"sidebar-title\">\n <span>schema</span>\n </div>\n <div class=\"sidebar-meta\" id=\"sidebarMeta\">—</div>\n </div>\n <div class=\"sidebar-search\">\n <svg class=\"sidebar-search-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <circle cx=\"11\" cy=\"11\" r=\"7\" />\n <path d=\"m21 21-4.3-4.3\" />\n </svg>\n <input id=\"sidebarSearch\" type=\"text\" placeholder=\"Filter tables…\" aria-label=\"Filter tables\" />\n </div>\n <div class=\"sidebar-scroll\">\n <div class=\"sidebar-group\" id=\"groupTables\">\n <div class=\"sidebar-group-header\" data-group=\"groupTables\">\n <span>Tables</span>\n <span class=\"chev\">▾</span>\n </div>\n <div class=\"sidebar-group-body\" id=\"tablesList\"></div>\n </div>\n <div class=\"sidebar-group collapsed\" id=\"groupEnums\">\n <div class=\"sidebar-group-header\" data-group=\"groupEnums\">\n <span>Enums</span>\n <span class=\"chev\">▾</span>\n </div>\n <div class=\"sidebar-group-body\" id=\"enumsList\"></div>\n </div>\n <div class=\"sidebar-group\" id=\"groupSaved\">\n <div class=\"sidebar-group-header\" data-group=\"groupSaved\">\n <span>Saved Queries</span>\n <span class=\"chev\">▾</span>\n </div>\n <div class=\"sidebar-group-body\" id=\"savedList\"></div>\n </div>\n </div>\n <div class=\"sidebar-resizer\" id=\"sidebarResizer\"></div>\n </aside>\n\n <!-- Main -->\n <main class=\"main\">\n <nav class=\"tabs\" role=\"tablist\">\n <button class=\"tab active\" data-tab=\"builder\" role=\"tab\" aria-selected=\"true\">\n <svg class=\"tab-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <path d=\"M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z\" />\n </svg>\n Query\n </button>\n <button class=\"tab\" data-tab=\"data\" role=\"tab\">\n <svg class=\"tab-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" />\n <path d=\"M3 9h18M3 15h18M9 3v18M15 3v18\" />\n </svg>\n Data\n </button>\n <button class=\"tab\" data-tab=\"schema\" role=\"tab\">\n <svg class=\"tab-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <ellipse cx=\"12\" cy=\"5\" rx=\"9\" ry=\"3\" />\n <path d=\"M3 5v14a9 3 0 0 0 18 0V5\" />\n <path d=\"M3 12a9 3 0 0 0 18 0\" />\n </svg>\n Schema\n </button>\n </nav>\n\n <!-- Data pane -->\n <section class=\"pane\" id=\"pane-data\" role=\"tabpanel\">\n <div class=\"pane-header\">\n <div class=\"pane-title\">\n <h1 id=\"dataTitle\">—</h1>\n <span class=\"muted\" id=\"dataTotal\"></span>\n </div>\n <div class=\"toolbar\">\n <div class=\"search-input\">\n <svg class=\"search-input-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <circle cx=\"11\" cy=\"11\" r=\"7\" />\n <path d=\"m21 21-4.3-4.3\" />\n </svg>\n <input id=\"dataSearch\" type=\"text\" placeholder=\"Search all text columns…\" aria-label=\"Full text search\" />\n </div>\n <button class=\"btn btn-ghost btn-sm\" id=\"dataRefresh\" aria-label=\"Refresh\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <path d=\"M21 12a9 9 0 1 1-3-6.7L21 8\" />\n <path d=\"M21 3v5h-5\" />\n </svg>\n Refresh\n </button>\n <button class=\"btn btn-primary btn-sm\" id=\"dataInsert\" aria-label=\"Insert row\" hidden>\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <path d=\"M12 5v14M5 12h14\" />\n </svg>\n Insert row\n </button>\n </div>\n </div>\n <div class=\"data-wrap\">\n <div class=\"data-scroll\" id=\"dataScroll\"></div>\n <div class=\"data-footer\">\n <span id=\"dataRange\">—</span>\n <div class=\"pagination\">\n <button class=\"btn btn-sm\" id=\"pagePrev\">← Prev</button>\n <span id=\"pageIndicator\">—</span>\n <button class=\"btn btn-sm\" id=\"pageNext\">Next →</button>\n </div>\n </div>\n </div>\n </section>\n\n <!-- Schema pane -->\n <section class=\"pane\" id=\"pane-schema\" role=\"tabpanel\">\n <div class=\"pane-header\">\n <div class=\"pane-title\">\n <h1 id=\"schemaTitle\">—</h1>\n <span class=\"muted\" id=\"schemaSub\"></span>\n </div>\n </div>\n <div class=\"schema-scroll\" id=\"schemaScroll\"></div>\n </section>\n\n <!-- SQL pane -->\n <!-- Query (visual findMany builder) pane -->\n <section class=\"pane active\" id=\"pane-builder\" role=\"tabpanel\">\n <div class=\"pane-header\">\n <div class=\"pane-title\">\n <h1>Query</h1>\n <span class=\"muted\" id=\"builderSub\">Visual findMany composer — no SQL required</span>\n </div>\n <div class=\"toolbar\">\n <button class=\"btn btn-primary\" id=\"builderRun\">\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"currentColor\">\n <path d=\"M8 5v14l11-7z\" />\n </svg>\n Run\n </button>\n <button class=\"btn\" id=\"builderSave\">Save</button>\n <button class=\"btn\" id=\"builderCopy\" title=\"Copy generated TypeScript to clipboard\">Copy TS</button>\n <button class=\"btn btn-ghost btn-sm\" id=\"builderReset\">Reset</button>\n </div>\n </div>\n <div class=\"builder-wrap\">\n <div class=\"builder-left\" id=\"builderLeft\"></div>\n <div class=\"builder-right\">\n <div class=\"builder-preview\" id=\"builderPreview\"></div>\n <div class=\"builder-results\">\n <div class=\"builder-results-header\" id=\"builderResultsHeader\">Results</div>\n <div class=\"data-scroll\" id=\"builderResults\" style=\"flex:1\"></div>\n </div>\n </div>\n </div>\n </section>\n </main>\n </div>\n </div>\n\n <!-- Modals -->\n <div class=\"modal-backdrop\" id=\"savePromptBackdrop\" aria-hidden=\"true\">\n <div class=\"modal\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"savePromptTitle\">\n <div class=\"modal-header\">\n <div class=\"modal-title\" id=\"savePromptTitle\">Save query</div>\n <button class=\"icon-btn\" data-close-modal=\"savePromptBackdrop\" aria-label=\"Close\">×</button>\n </div>\n <div class=\"modal-body\">\n <div class=\"modal-field\">\n <label for=\"savePromptName\">Name</label>\n <input id=\"savePromptName\" type=\"text\" placeholder=\"e.g. recent active users\" />\n </div>\n <div class=\"modal-field\">\n <label for=\"savePromptTable\">Target table</label>\n <select id=\"savePromptTable\"></select>\n </div>\n </div>\n <div class=\"modal-footer\">\n <button class=\"btn btn-ghost\" data-close-modal=\"savePromptBackdrop\">Cancel</button>\n <button class=\"btn btn-primary\" id=\"savePromptConfirm\">Save</button>\n </div>\n </div>\n </div>\n\n <div class=\"modal-backdrop\" id=\"jsonModalBackdrop\" aria-hidden=\"true\">\n <div class=\"modal\" role=\"dialog\" aria-modal=\"true\" style=\"width: 640px\">\n <div class=\"modal-header\">\n <div class=\"modal-title\">JSON value</div>\n <button class=\"icon-btn\" data-close-modal=\"jsonModalBackdrop\" aria-label=\"Close\">×</button>\n </div>\n <div class=\"modal-body\">\n <div class=\"json-view\" id=\"jsonModalBody\"></div>\n </div>\n </div>\n </div>\n\n <div class=\"modal-backdrop\" id=\"paletteBackdrop\" aria-hidden=\"true\">\n <div class=\"modal palette\" role=\"dialog\" aria-modal=\"true\">\n <input\n class=\"palette-input\"\n id=\"paletteInput\"\n type=\"text\"\n placeholder=\"Jump to table, run command…\"\n aria-label=\"Command palette input\"\n />\n <div class=\"palette-list\" id=\"paletteList\"></div>\n </div>\n </div>\n\n <!-- Row editor (write mode only): insert / edit a single row -->\n <div class=\"modal-backdrop\" id=\"rowEditorBackdrop\" aria-hidden=\"true\">\n <div class=\"modal\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"rowEditorTitle\" style=\"width: 560px\">\n <div class=\"modal-header\">\n <div class=\"modal-title\" id=\"rowEditorTitle\">Edit row</div>\n <button class=\"icon-btn\" data-close-modal=\"rowEditorBackdrop\" aria-label=\"Close\">×</button>\n </div>\n <div class=\"modal-body\">\n <div class=\"row-editor-hint\" id=\"rowEditorHint\"></div>\n <div class=\"row-editor-fields\" id=\"rowEditorFields\"></div>\n <div class=\"row-editor-error\" id=\"rowEditorError\" hidden></div>\n </div>\n <div class=\"modal-footer\">\n <button class=\"btn btn-ghost\" data-close-modal=\"rowEditorBackdrop\">Cancel</button>\n <button class=\"btn btn-primary\" id=\"rowEditorConfirm\">Save</button>\n </div>\n </div>\n </div>\n\n <div class=\"toast-wrap\" id=\"toastWrap\" aria-live=\"polite\"></div>\n\n <script nonce=\"__CSP_NONCE__\">\n // =====================================================================\n // State\n // =====================================================================\n const State = {\n schema: null, // full /api/schema response\n tables: [], // [{ name, columns, relations, estimatedRows, primaryKey }]\n tablesByName: new Map(),\n enums: {},\n currentTable: null,\n currentTab: 'data',\n connOk: false,\n // write mode / PII (from /api/schema)\n writable: false,\n showPii: false,\n // data pane\n data: {\n rows: [],\n columns: [],\n total: 0,\n limit: 50,\n offset: 0,\n orderBy: null,\n dir: 'asc',\n search: '',\n loading: false,\n error: null,\n },\n // builder (Query) pane\n builder: {\n table: null,\n args: emptyBuilderArgs(),\n result: null,\n error: null,\n running: false,\n validation: {},\n },\n savedQueries: [],\n sidebarFilter: '',\n // save prompt context (builder queries only)\n savePrompt: {\n kind: 'builder',\n args: null,\n },\n };\n\n function emptyBuilderArgs() {\n return {\n combinator: 'AND',\n where: [],\n with: [], // [{ relation, args: {...} }]\n orderBy: { column: null, dir: 'asc' },\n limit: 10,\n select: [],\n omit: [],\n };\n }\n\n // =====================================================================\n // Storage (localStorage wrapper)\n // =====================================================================\n const Storage = {\n PREFIX: 'turbine_studio_',\n get(key, fallback) {\n try {\n const raw = localStorage.getItem(Storage.PREFIX + key);\n if (raw == null) return fallback;\n return JSON.parse(raw);\n } catch {\n return fallback;\n }\n },\n set(key, val) {\n try {\n localStorage.setItem(Storage.PREFIX + key, JSON.stringify(val));\n } catch {\n /* ignore quota */\n }\n },\n del(key) {\n try {\n localStorage.removeItem(Storage.PREFIX + key);\n } catch {\n /* ignore */\n }\n },\n };\n\n // =====================================================================\n // API client\n // =====================================================================\n const Api = {\n async _fetch(path, opts = {}) {\n const res = await fetch(path, {\n credentials: 'same-origin',\n headers: { 'Content-Type': 'application/json' },\n ...opts,\n });\n let body = null;\n try {\n body = await res.json();\n } catch {\n /* non-JSON response */\n }\n if (!res.ok) {\n const err = new Error((body && body.error) || res.statusText || 'Request failed');\n err.status = res.status;\n throw err;\n }\n return body;\n },\n schema() {\n return Api._fetch('/api/schema');\n },\n rows(table, params) {\n const qs = new URLSearchParams();\n if (params) {\n for (const [k, v] of Object.entries(params)) {\n if (v !== undefined && v !== null && v !== '') qs.set(k, String(v));\n }\n }\n const q = qs.toString();\n return Api._fetch('/api/tables/' + encodeURIComponent(table) + (q ? '?' + q : ''));\n },\n runBuilder(table, args) {\n return Api._fetch('/api/builder', {\n method: 'POST',\n body: JSON.stringify({ table, args }),\n });\n },\n // Write mode only (the routes 404 in read-only mode). `fetch` sets a\n // same-origin Origin header on POST, which the server requires (CSRF).\n rowInsert(table, data) {\n return Api._fetch('/api/row/insert', {\n method: 'POST',\n body: JSON.stringify({ table, data }),\n });\n },\n rowUpdate(table, where, data) {\n return Api._fetch('/api/row/update', {\n method: 'POST',\n body: JSON.stringify({ table, where, data }),\n });\n },\n rowDelete(table, where) {\n return Api._fetch('/api/row/delete', {\n method: 'POST',\n body: JSON.stringify({ table, where }),\n });\n },\n savedQueries(table) {\n const qs = table ? '?table=' + encodeURIComponent(table) : '';\n return Api._fetch('/api/saved-queries' + qs);\n },\n saveQuery(payload) {\n return Api._fetch('/api/saved-queries', {\n method: 'POST',\n body: JSON.stringify(payload),\n });\n },\n deleteSaved(id) {\n return Api._fetch('/api/saved-queries/' + encodeURIComponent(id), {\n method: 'DELETE',\n });\n },\n };\n\n // =====================================================================\n // Utilities\n // =====================================================================\n const $ = (sel, root) => (root || document).querySelector(sel);\n const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel));\n\n function el(tag, attrs = {}, children = []) {\n const node = document.createElement(tag);\n for (const [k, v] of Object.entries(attrs)) {\n if (k === 'class') node.className = v;\n else if (k === 'text') node.textContent = v;\n else if (k === 'html') node.innerHTML = v;\n else if (k.startsWith('on') && typeof v === 'function') {\n node.addEventListener(k.slice(2).toLowerCase(), v);\n } else if (k === 'style' && typeof v === 'object') {\n Object.assign(node.style, v);\n } else if (v !== undefined && v !== null && v !== false) {\n node.setAttribute(k, v === true ? '' : String(v));\n }\n }\n const list = Array.isArray(children) ? children : [children];\n for (const c of list) {\n if (c == null || c === false) continue;\n if (typeof c === 'string' || typeof c === 'number') {\n node.appendChild(document.createTextNode(String(c)));\n } else {\n node.appendChild(c);\n }\n }\n return node;\n }\n\n function escapeHtml(s) {\n if (s == null) return '';\n return String(s)\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n }\n\n function formatCount(n) {\n if (n == null) return '—';\n if (n < 1000) return String(n);\n if (n < 1_000_000) return (n / 1000).toFixed(n < 10_000 ? 1 : 0).replace(/\\.0$/, '') + 'k';\n if (n < 1_000_000_000) return (n / 1_000_000).toFixed(n < 10_000_000 ? 1 : 0).replace(/\\.0$/, '') + 'M';\n return (n / 1_000_000_000).toFixed(1).replace(/\\.0$/, '') + 'B';\n }\n\n function formatNumber(n) {\n if (n == null) return '—';\n return Number(n).toLocaleString();\n }\n\n function debounce(fn, ms) {\n let t;\n return (...args) => {\n clearTimeout(t);\n t = setTimeout(() => fn(...args), ms);\n };\n }\n\n function isTypingInInput() {\n const a = document.activeElement;\n if (!a) return false;\n const tag = a.tagName;\n return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || a.isContentEditable;\n }\n\n function isMac() {\n return navigator.platform.toLowerCase().includes('mac');\n }\n\n function toast(msg, kind) {\n const t = el('div', { class: 'toast ' + (kind || ''), text: msg });\n $('#toastWrap').appendChild(t);\n setTimeout(() => {\n t.style.transition = 'opacity 200ms';\n t.style.opacity = '0';\n setTimeout(() => t.remove(), 220);\n }, 2400);\n }\n\n async function copyToClipboard(text, label) {\n if (!text) {\n toast('Nothing to copy', 'err');\n return;\n }\n try {\n if (navigator.clipboard && navigator.clipboard.writeText) {\n await navigator.clipboard.writeText(text);\n } else {\n // Fallback for non-secure contexts / older browsers\n const ta = document.createElement('textarea');\n ta.value = text;\n ta.style.position = 'fixed';\n ta.style.left = '-9999px';\n document.body.appendChild(ta);\n ta.select();\n document.execCommand('copy');\n document.body.removeChild(ta);\n }\n toast((label || 'Copied') + ' to clipboard', 'ok');\n } catch (err) {\n toast('Copy failed: ' + (err && err.message ? err.message : 'unknown'), 'err');\n }\n }\n\n function isStringType(ts) {\n return ts === 'string';\n }\n function isNumericType(ts) {\n return ts === 'number' || ts === 'bigint';\n }\n function isBoolType(ts) {\n return ts === 'boolean';\n }\n function isDateType(ts) {\n return ts === 'Date';\n }\n\n function operatorsForType(ts) {\n const base = ['equals', 'not', 'in', 'notIn'];\n if (isStringType(ts)) return base.concat(['contains', 'startsWith', 'endsWith']);\n if (isNumericType(ts) || isDateType(ts)) return base.concat(['lt', 'lte', 'gt', 'gte']);\n return base;\n }\n\n // =====================================================================\n // Sidebar\n // =====================================================================\n const Sidebar = {\n render() {\n const tablesList = $('#tablesList');\n const enumsList = $('#enumsList');\n tablesList.innerHTML = '';\n enumsList.innerHTML = '';\n\n const filter = State.sidebarFilter.toLowerCase();\n const filtered = State.tables.filter((t) =>\n !filter || t.name.toLowerCase().includes(filter)\n );\n\n if (!filtered.length) {\n tablesList.appendChild(el('div', { class: 'sidebar-empty', text: 'No tables' }));\n } else {\n for (const t of filtered) {\n const active = State.currentTable && State.currentTable.name === t.name;\n const item = el(\n 'div',\n {\n class: 'sidebar-item' + (active ? ' active' : ''),\n role: 'button',\n tabindex: '0',\n 'aria-label': 'Table ' + t.name,\n onclick: () => Router.selectTable(t.name),\n onkeydown: (e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n Router.selectTable(t.name);\n }\n },\n },\n [\n el('span', { class: 'sidebar-item-name', text: t.name }),\n el('span', { class: 'sidebar-item-count', text: formatCount(t.estimatedRows) }),\n ]\n );\n tablesList.appendChild(item);\n }\n }\n\n const enumEntries = Object.entries(State.enums || {});\n if (!enumEntries.length) {\n enumsList.appendChild(el('div', { class: 'sidebar-empty', text: 'No enums' }));\n } else {\n for (const [name, values] of enumEntries) {\n enumsList.appendChild(\n el(\n 'div',\n {\n class: 'sidebar-item',\n title: (values || []).join(', '),\n },\n [\n el('span', { class: 'sidebar-item-name', text: name }),\n el('span', { class: 'sidebar-item-count', text: String((values || []).length) }),\n ]\n )\n );\n }\n }\n\n Sidebar.renderSaved();\n },\n\n renderSaved() {\n const host = $('#savedList');\n host.innerHTML = '';\n if (!State.savedQueries.length) {\n host.appendChild(el('div', { class: 'sidebar-empty', text: 'No saved queries' }));\n return;\n }\n const byTable = new Map();\n for (const q of State.savedQueries) {\n if (!byTable.has(q.table)) byTable.set(q.table, []);\n byTable.get(q.table).push(q);\n }\n const ordered = Array.from(byTable.keys()).sort();\n for (const table of ordered) {\n host.appendChild(el('div', { class: 'saved-table-header', text: table }));\n for (const q of byTable.get(table)) {\n const row = el('div', { class: 'saved-query', onclick: () => SavedQueries.load(q) }, [\n el('span', { class: 'saved-query-name', text: q.name, title: q.name }),\n el('span', { class: 'saved-kind', text: q.kind }),\n el('button', {\n class: 'saved-query-del icon-btn',\n 'aria-label': 'Delete saved query',\n onclick: (e) => {\n e.stopPropagation();\n SavedQueries.remove(q.id);\n },\n text: '×',\n }),\n ]);\n host.appendChild(row);\n }\n }\n },\n\n bind() {\n $('#sidebarSearch').addEventListener('input', (e) => {\n State.sidebarFilter = e.target.value;\n Sidebar.render();\n });\n $$('.sidebar-group-header').forEach((h) => {\n h.addEventListener('click', () => {\n const g = document.getElementById(h.dataset.group);\n if (g) g.classList.toggle('collapsed');\n });\n });\n\n // Resizer\n const resizer = $('#sidebarResizer');\n const sidebar = $('#sidebar');\n let startX = 0;\n let startW = 0;\n const onMove = (e) => {\n const dx = e.clientX - startX;\n const next = Math.min(480, Math.max(200, startW + dx));\n document.documentElement.style.setProperty('--sidebar-w', next + 'px');\n };\n const onUp = () => {\n resizer.classList.remove('dragging');\n document.removeEventListener('mousemove', onMove);\n document.removeEventListener('mouseup', onUp);\n document.body.style.userSelect = '';\n const cur = getComputedStyle(document.documentElement).getPropertyValue('--sidebar-w');\n Storage.set('sidebarW', cur.trim());\n };\n resizer.addEventListener('mousedown', (e) => {\n startX = e.clientX;\n startW = sidebar.getBoundingClientRect().width;\n resizer.classList.add('dragging');\n document.body.style.userSelect = 'none';\n document.addEventListener('mousemove', onMove);\n document.addEventListener('mouseup', onUp);\n });\n\n $('#hamburger').addEventListener('click', () => {\n sidebar.classList.toggle('open');\n });\n },\n };\n\n // =====================================================================\n // Router / tabs\n // =====================================================================\n const Router = {\n selectTab(tab) {\n State.currentTab = tab;\n Storage.set('tab', tab);\n $$('.tab').forEach((b) =>\n b.classList.toggle('active', b.dataset.tab === tab)\n );\n $$('.pane').forEach((p) =>\n p.classList.toggle('active', p.id === 'pane-' + tab)\n );\n if (tab === 'schema') SchemaPane.render();\n if (tab === 'data') DataPane.render();\n if (tab === 'builder') BuilderPane.render();\n },\n\n selectTable(name) {\n const t = State.tablesByName.get(name);\n if (!t) return;\n State.currentTable = t;\n State.data.offset = 0;\n State.data.orderBy = (t.primaryKey && t.primaryKey[0]) || (t.columns[0] && t.columns[0].name);\n State.data.dir = 'asc';\n State.data.search = '';\n Storage.set('currentTable', name);\n Sidebar.render();\n if (State.currentTab === 'data') DataPane.loadAndRender();\n if (State.currentTab === 'schema') SchemaPane.render();\n if (State.currentTab === 'builder') {\n BuilderPane.resetForTable(name);\n }\n // always close mobile sidebar after selecting\n $('#sidebar').classList.remove('open');\n },\n\n bind() {\n $$('.tab').forEach((btn) => {\n btn.addEventListener('click', () => Router.selectTab(btn.dataset.tab));\n });\n },\n };\n\n // =====================================================================\n // Data pane\n // =====================================================================\n const DataPane = {\n render() {\n const t = State.currentTable;\n if (!t) {\n $('#dataTitle').textContent = '—';\n $('#dataTotal').textContent = '';\n $('#dataScroll').innerHTML = '<div class=\"empty\">Select a table to browse data.</div>';\n $('#dataRange').textContent = '—';\n $('#pageIndicator').textContent = '—';\n return;\n }\n $('#dataTitle').textContent = t.name;\n $('#dataTotal').textContent = State.data.loading ? 'loading…' : formatNumber(State.data.total) + ' rows';\n\n const host = $('#dataScroll');\n\n if (State.data.error) {\n host.innerHTML = '';\n host.appendChild(\n el('div', { class: 'error-box' }, [\n el('div', { class: 'error-box-title', text: 'Error loading data' }),\n document.createTextNode(State.data.error),\n ])\n );\n $('#dataRange').textContent = '—';\n $('#pageIndicator').textContent = '—';\n return;\n }\n\n if (State.data.loading) {\n host.innerHTML = '';\n const skel = el('div');\n for (let i = 0; i < 12; i++) {\n const row = el('div', { class: 'skeleton-row' });\n for (let c = 0; c < 5; c++) {\n row.appendChild(el('div', { class: 'skeleton-cell' }));\n }\n skel.appendChild(row);\n }\n host.appendChild(skel);\n return;\n }\n\n if (!State.data.rows.length) {\n host.innerHTML = '';\n host.appendChild(\n el('div', { class: 'empty' }, [\n el(\n 'svg',\n {\n class: 'empty-icon',\n viewBox: '0 0 24 24',\n fill: 'none',\n stroke: 'currentColor',\n 'stroke-width': '2',\n },\n []\n ),\n document.createTextNode(\n State.data.search ? 'No rows match your search.' : 'No rows.'\n ),\n ])\n );\n $('#dataRange').textContent = '0 of 0';\n $('#pageIndicator').textContent = '—';\n return;\n }\n\n host.innerHTML = '';\n const cols = State.data.columns.length\n ? State.data.columns.map((c) => c.name)\n : Object.keys(State.data.rows[0] || {});\n host.appendChild(Table.render(cols, State.data.rows, {\n sortable: true,\n orderBy: State.data.orderBy,\n dir: State.data.dir,\n actions: State.writable ? (row) => DataPane.rowActions(row) : null,\n onSort: (col) => {\n if (State.data.orderBy === col) {\n State.data.dir = State.data.dir === 'asc' ? 'desc' : 'asc';\n } else {\n State.data.orderBy = col;\n State.data.dir = 'asc';\n }\n State.data.offset = 0;\n DataPane.loadAndRender();\n },\n }));\n\n const start = State.data.total ? State.data.offset + 1 : 0;\n const end = Math.min(State.data.offset + State.data.limit, State.data.total);\n $('#dataRange').textContent = start + '-' + end + ' of ' + formatNumber(State.data.total);\n const page = Math.floor(State.data.offset / State.data.limit) + 1;\n const pages = Math.max(1, Math.ceil(State.data.total / State.data.limit));\n $('#pageIndicator').textContent = 'Page ' + page + ' / ' + pages;\n $('#pagePrev').disabled = State.data.offset === 0;\n $('#pageNext').disabled = State.data.offset + State.data.limit >= State.data.total;\n },\n\n // Per-row edit/delete controls (write mode only).\n rowActions(row) {\n const wrap = el('div', { class: 'row-actions' });\n wrap.appendChild(\n el('button', {\n class: 'row-action-btn',\n text: 'Edit',\n onclick: (e) => {\n e.stopPropagation();\n RowEditor.openEdit(row);\n },\n })\n );\n wrap.appendChild(\n el('button', {\n class: 'row-action-btn danger',\n text: 'Delete',\n onclick: (e) => {\n e.stopPropagation();\n DataPane.deleteRow(row);\n },\n })\n );\n return wrap;\n },\n\n async deleteRow(row) {\n const t = State.currentTable;\n if (!t || !State.writable) return;\n const pks = t.primaryKey || [];\n if (!pks.length) {\n toast('Table has no primary key; cannot delete a single row', 'err');\n return;\n }\n const where = {};\n for (const pk of pks) where[pk] = row[pk];\n const label = pks.map((k) => k + '=' + row[k]).join(', ');\n if (!window.confirm('Delete row where ' + label + '? This cannot be undone.')) return;\n try {\n await Api.rowDelete(t.name, where);\n toast('Row deleted', 'ok');\n DataPane.loadAndRender();\n } catch (err) {\n toast('Delete failed: ' + (err && err.message ? err.message : 'unknown'), 'err');\n }\n },\n\n async loadAndRender() {\n const t = State.currentTable;\n if (!t) return;\n State.data.loading = true;\n State.data.error = null;\n DataPane.render();\n try {\n const params = {\n limit: State.data.limit,\n offset: State.data.offset,\n orderBy: State.data.orderBy,\n dir: State.data.dir,\n search: State.data.search,\n };\n const res = await Api.rows(t.name, params);\n State.data.rows = res.rows || [];\n State.data.columns = res.columns || [];\n State.data.total = res.total || 0;\n } catch (err) {\n State.data.error = err.message;\n State.data.rows = [];\n State.data.total = 0;\n } finally {\n State.data.loading = false;\n DataPane.render();\n }\n },\n\n bind() {\n $('#dataSearch').addEventListener(\n 'input',\n debounce((e) => {\n State.data.search = e.target.value;\n State.data.offset = 0;\n DataPane.loadAndRender();\n }, 240)\n );\n $('#dataRefresh').addEventListener('click', () => DataPane.loadAndRender());\n $('#dataInsert').addEventListener('click', () => RowEditor.openInsert());\n $('#pagePrev').addEventListener('click', () => {\n if (State.data.offset === 0) return;\n State.data.offset = Math.max(0, State.data.offset - State.data.limit);\n DataPane.loadAndRender();\n });\n $('#pageNext').addEventListener('click', () => {\n if (State.data.offset + State.data.limit >= State.data.total) return;\n State.data.offset += State.data.limit;\n DataPane.loadAndRender();\n });\n },\n };\n\n // =====================================================================\n // Shared data table renderer\n // =====================================================================\n const Table = {\n render(cols, rows, opts = {}) {\n const table = el('table', { class: 'data' });\n const thead = el('thead');\n const headRow = el('tr');\n for (const c of cols) {\n const attrs = {\n class: opts.sortable ? 'sortable' : '',\n scope: 'col',\n };\n if (opts.sortable) {\n attrs.onclick = () => opts.onSort && opts.onSort(c);\n }\n const th = el('th', attrs);\n th.appendChild(document.createTextNode(c));\n if (opts.sortable && opts.orderBy === c) {\n th.appendChild(el('span', { class: 'sort-ind', text: opts.dir === 'asc' ? '▲' : '▼' }));\n }\n headRow.appendChild(th);\n }\n if (opts.actions) {\n headRow.appendChild(el('th', { scope: 'col', text: '' }));\n }\n thead.appendChild(headRow);\n table.appendChild(thead);\n\n const tbody = el('tbody');\n for (const row of rows) {\n const tr = el('tr');\n for (const c of cols) {\n const val = row[c];\n tr.appendChild(Table.renderCell(val));\n }\n if (opts.actions) {\n const td = el('td');\n td.appendChild(opts.actions(row));\n tr.appendChild(td);\n }\n tbody.appendChild(tr);\n }\n table.appendChild(tbody);\n return table;\n },\n\n renderCell(val) {\n const td = el('td');\n if (val === null || val === undefined) {\n td.className = 'cell-null';\n td.textContent = 'null';\n td.title = 'null';\n return td;\n }\n if (typeof val === 'number') {\n td.className = 'cell-number';\n td.textContent = String(val);\n td.title = String(val);\n return td;\n }\n if (typeof val === 'boolean') {\n td.className = 'cell-bool';\n td.textContent = String(val);\n td.title = String(val);\n return td;\n }\n if (val && typeof val === 'object') {\n td.className = 'cell-json';\n const str = JSON.stringify(val);\n const preview = str.length > 48 ? str.slice(0, 45) + '…' : str;\n td.textContent = preview;\n td.title = 'JSON (click to expand)';\n const btn = el('button', {\n class: 'expand-btn',\n text: 'expand',\n onclick: (e) => {\n e.stopPropagation();\n JsonModal.open(val);\n },\n });\n td.appendChild(btn);\n return td;\n }\n const s = String(val);\n td.textContent = s;\n td.title = s;\n return td;\n },\n };\n\n // =====================================================================\n // Schema pane\n // =====================================================================\n const SchemaPane = {\n render() {\n const t = State.currentTable;\n const host = $('#schemaScroll');\n if (!t) {\n $('#schemaTitle').textContent = '—';\n $('#schemaSub').textContent = '';\n host.innerHTML = '<div class=\"empty\">Select a table to inspect its schema.</div>';\n return;\n }\n $('#schemaTitle').textContent = t.name;\n $('#schemaSub').textContent =\n (t.columns.length + ' columns · ' + (t.relations || []).length + ' relations');\n host.innerHTML = '';\n\n // Columns\n const colsSection = el('section', { class: 'schema-section' });\n colsSection.appendChild(el('h2', { text: 'Columns' }));\n const colTable = el('table', { class: 'col-table' });\n const colHead = el('thead');\n colHead.appendChild(\n el('tr', {}, ['Name', 'TS', 'PG', 'Attributes'].map((h) => el('th', { text: h })))\n );\n colTable.appendChild(colHead);\n const colBody = el('tbody');\n for (const c of t.columns) {\n const tr = el('tr');\n tr.appendChild(\n el('td', {}, [\n el('span', {\n class: 'col-name' + (c.isPrimaryKey ? ' pk' : ''),\n text: c.name,\n }),\n ])\n );\n tr.appendChild(el('td', {}, [el('span', { class: 'col-ts', text: c.tsType || '—' })]));\n tr.appendChild(el('td', {}, [el('span', { class: 'col-pg', text: c.pgType || '—' })]));\n const attrs = el('td');\n if (c.isPrimaryKey) attrs.appendChild(el('span', { class: 'badge pk', text: 'PK' }));\n if (!c.nullable) attrs.appendChild(el('span', { class: 'badge nn', text: 'NOT NULL' }));\n else attrs.appendChild(el('span', { class: 'badge nullable', text: 'nullable' }));\n if (c.hasDefault) attrs.appendChild(el('span', { class: 'badge def', text: 'default' }));\n if (c.pii) attrs.appendChild(el('span', { class: 'badge pii', text: 'PII' }));\n tr.appendChild(attrs);\n colBody.appendChild(tr);\n }\n colTable.appendChild(colBody);\n colsSection.appendChild(colTable);\n host.appendChild(colsSection);\n\n // Relations\n const rels = t.relations || [];\n if (rels.length) {\n const relsSection = el('section', { class: 'schema-section' });\n relsSection.appendChild(el('h2', { text: 'Relations' }));\n const relTable = el('table', { class: 'col-table' });\n const relHead = el('thead');\n relHead.appendChild(\n el('tr', {}, ['Name', 'Type', 'Target', 'Keys'].map((h) => el('th', { text: h })))\n );\n relTable.appendChild(relHead);\n const relBody = el('tbody');\n for (const r of rels) {\n const tr = el('tr');\n tr.appendChild(el('td', {}, [el('span', { class: 'col-rel-name', text: r.name })]));\n tr.appendChild(el('td', {}, [el('span', { class: 'badge rel-type', text: r.type })]));\n tr.appendChild(el('td', {}, [el('span', { class: 'col-name', text: r.to })]));\n tr.appendChild(\n el('td', {}, [\n el('span', {\n class: 'col-pg',\n text: (r.foreignKey || '—') + ' → ' + (r.referenceKey || '—'),\n }),\n ])\n );\n relBody.appendChild(tr);\n }\n relTable.appendChild(relBody);\n relsSection.appendChild(relTable);\n host.appendChild(relsSection);\n }\n\n // CREATE TABLE\n const details = el('details', { class: 'schema-details' });\n details.appendChild(el('summary', { text: 'CREATE TABLE SQL' }));\n const pre = el('pre');\n pre.innerHTML = SchemaPane.renderCreateTable(t);\n details.appendChild(pre);\n host.appendChild(details);\n },\n\n renderCreateTable(t) {\n const lines = [];\n lines.push(\n '<span class=\"sql-kw\">CREATE TABLE</span> <span class=\"sql-ident\">\"' +\n escapeHtml(t.name) +\n '\"</span> ('\n );\n const colLines = t.columns.map((c) => {\n let line = ' <span class=\"sql-ident\">\"' + escapeHtml(c.name) + '\"</span> ';\n line += '<span class=\"sql-type\">' + escapeHtml(c.pgType || 'text') + '</span>';\n if (!c.nullable) line += ' <span class=\"sql-kw\">NOT NULL</span>';\n if (c.hasDefault) line += ' <span class=\"sql-kw\">DEFAULT</span> <span class=\"sql-str\">…</span>';\n return line;\n });\n if (t.primaryKey && t.primaryKey.length) {\n colLines.push(\n ' <span class=\"sql-kw\">PRIMARY KEY</span> (' +\n t.primaryKey.map((k) => '<span class=\"sql-ident\">\"' + escapeHtml(k) + '\"</span>').join(', ') +\n ')'\n );\n }\n lines.push(colLines.join(',\\n'));\n lines.push(');');\n return lines.join('\\n');\n },\n };\n\n // =====================================================================\n // Builder pane\n // =====================================================================\n const BuilderPane = {\n resetForTable(tableName) {\n State.builder.table = tableName;\n State.builder.args = emptyBuilderArgs();\n State.builder.result = null;\n State.builder.error = null;\n State.builder.validation = {};\n BuilderPane.render();\n },\n\n findTable(name) {\n return State.tablesByName.get(name);\n },\n\n render() {\n const host = $('#builderLeft');\n host.innerHTML = '';\n if (!State.tables.length) {\n host.appendChild(el('div', { class: 'empty', text: 'Loading schema…' }));\n BuilderPane.updatePreview();\n return;\n }\n if (!State.builder.table) {\n State.builder.table =\n (State.currentTable && State.currentTable.name) ||\n (State.tables[0] && State.tables[0].name);\n }\n $('#builderSub').textContent = 'Visual findMany composer';\n\n // Table picker\n const tablePick = el('div', { class: 'builder-section' }, [\n el('div', { class: 'builder-section-title' }, [el('h3', { text: 'Table' })]),\n (() => {\n const sel = el('select', { class: 'builder-select', id: 'builderTable' });\n for (const t of State.tables) {\n sel.appendChild(el('option', { value: t.name, text: t.name }));\n }\n sel.value = State.builder.table;\n sel.addEventListener('change', () => {\n BuilderPane.resetForTable(sel.value);\n });\n return sel;\n })(),\n ]);\n host.appendChild(tablePick);\n\n const tableMeta = BuilderPane.findTable(State.builder.table);\n if (!tableMeta) {\n host.appendChild(el('div', { class: 'error-box', text: 'Unknown table.' }));\n return;\n }\n\n // WHERE\n host.appendChild(BuilderPane.renderWhere(State.builder.args, tableMeta, []));\n // WITH\n host.appendChild(BuilderPane.renderWith(State.builder.args, tableMeta, []));\n // ORDER BY / LIMIT\n host.appendChild(BuilderPane.renderOrderLimit(State.builder.args, tableMeta));\n // SELECT / OMIT\n host.appendChild(BuilderPane.renderSelectOmit(State.builder.args, tableMeta));\n\n BuilderPane.updatePreview();\n BuilderPane.updateRunButton();\n },\n\n renderWhere(args, tableMeta, path) {\n const section = el('div', { class: 'builder-section' });\n const header = el('div', { class: 'builder-section-title' }, [\n el('h3', { text: 'where' }),\n (() => {\n const pick = el('div', { class: 'combinator-pick' });\n for (const c of ['AND', 'OR', 'NOT']) {\n pick.appendChild(\n el('button', {\n class: args.combinator === c ? 'active' : '',\n text: c,\n onclick: () => {\n args.combinator = c;\n BuilderPane.render();\n },\n })\n );\n }\n return pick;\n })(),\n ]);\n section.appendChild(header);\n\n const wrap = el('div', { class: 'where-clauses' });\n args.where.forEach((clause, idx) => {\n wrap.appendChild(BuilderPane.renderClause(args, clause, idx, tableMeta));\n });\n section.appendChild(wrap);\n\n const addRow = el('div', { class: 'clause-row', style: { marginTop: '8px' } }, [\n el('button', {\n class: 'btn btn-sm',\n text: '+ Clause',\n onclick: () => {\n args.where.push({\n column: (tableMeta.columns[0] || {}).name || '',\n op: 'equals',\n value: '',\n insensitive: false,\n });\n BuilderPane.render();\n },\n }),\n ]);\n section.appendChild(addRow);\n\n return section;\n },\n\n renderClause(args, clause, idx, tableMeta) {\n const row = el('div', { class: 'where-clause' });\n // column select\n const colSel = el('select', { class: 'builder-select' });\n for (const c of tableMeta.columns) {\n const opt = el('option', { value: c.name, text: c.name });\n if (c.name === clause.column) opt.selected = true;\n colSel.appendChild(opt);\n }\n colSel.addEventListener('change', () => {\n clause.column = colSel.value;\n const col = tableMeta.columns.find((c) => c.name === clause.column);\n const ops = col ? operatorsForType(col.tsType) : ['equals'];\n if (!ops.includes(clause.op)) clause.op = ops[0];\n BuilderPane.render();\n });\n row.appendChild(colSel);\n\n // op select\n const col = tableMeta.columns.find((c) => c.name === clause.column);\n const ops = col ? operatorsForType(col.tsType) : ['equals'];\n const opSel = el('select', { class: 'builder-select' });\n for (const op of ops) {\n const opt = el('option', { value: op, text: op });\n if (op === clause.op) opt.selected = true;\n opSel.appendChild(opt);\n }\n opSel.addEventListener('change', () => {\n clause.op = opSel.value;\n BuilderPane.render();\n });\n row.appendChild(opSel);\n\n // value input\n const valInput = el('input', {\n class: 'builder-input',\n type: col && isNumericType(col.tsType) ? 'number' : 'text',\n placeholder: 'value',\n value: clause.value != null ? String(clause.value) : '',\n });\n valInput.addEventListener('input', () => {\n clause.value = valInput.value;\n BuilderPane.updatePreview();\n BuilderPane.updateRunButton();\n });\n row.appendChild(valInput);\n\n // remove\n const rm = el('button', {\n class: 'btn-ghost icon-btn',\n 'aria-label': 'Remove clause',\n text: '×',\n onclick: () => {\n args.where.splice(idx, 1);\n BuilderPane.render();\n },\n });\n row.appendChild(rm);\n\n // insensitive toggle for string cols\n if (col && isStringType(col.tsType) && ['contains', 'startsWith', 'endsWith', 'equals'].includes(clause.op)) {\n const ci = el('div', { class: 'clause-row', style: { gridColumn: '1 / -1' } }, [\n el(\n 'button',\n {\n class: 'chip' + (clause.insensitive ? ' active' : ''),\n text: 'mode: insensitive',\n onclick: () => {\n clause.insensitive = !clause.insensitive;\n BuilderPane.render();\n },\n },\n []\n ),\n ]);\n const both = el('div', { class: 'builder-field' });\n both.appendChild(row);\n both.appendChild(ci);\n return both;\n }\n return row;\n },\n\n renderWith(args, tableMeta, path) {\n const section = el('div', { class: 'builder-section' });\n section.appendChild(\n el('div', { class: 'builder-section-title' }, [el('h3', { text: 'with (relations)' })])\n );\n const rels = tableMeta.relations || [];\n // Depth guard — the engine caps `with` nesting at depth 10\n // (CircularRelationError). Stop offering deeper expansion before then so\n // the UI can never compose a query the engine will reject.\n if (path.length >= 8) {\n section.appendChild(\n el('div', { class: 'field-hint', style: { color: 'var(--text-muted)' }, text: 'Max relation depth reached.' })\n );\n return section;\n }\n if (!rels.length) {\n section.appendChild(\n el('div', { class: 'field-hint', style: { color: 'var(--text-muted)' }, text: 'No relations.' })\n );\n return section;\n }\n const wrap = el('div', { class: 'with-rels' });\n for (const rel of rels) {\n const existing = args.with.find((w) => w.relation === rel.name);\n const active = !!existing;\n const relBox = el('div', { class: 'with-rel' + (existing && existing.expanded ? ' expanded' : '') });\n const head = el('div', { class: 'with-rel-header' }, [\n el('div', {}, [\n el('span', { class: 'chev', text: '›' }),\n el('span', { class: 'with-rel-name', text: ' ' + rel.name }),\n el('span', { class: 'with-rel-meta', text: ' · ' + rel.type + ' → ' + rel.to }),\n ]),\n el(\n 'button',\n {\n class: 'chip' + (active ? ' active' : ''),\n text: active ? 'included' : 'include',\n onclick: (e) => {\n e.stopPropagation();\n if (active) {\n args.with = args.with.filter((w) => w.relation !== rel.name);\n } else {\n args.with.push({\n relation: rel.name,\n expanded: true,\n args: emptyBuilderArgs(),\n });\n }\n BuilderPane.render();\n },\n },\n []\n ),\n ]);\n head.addEventListener('click', () => {\n if (existing) {\n existing.expanded = !existing.expanded;\n BuilderPane.render();\n }\n });\n relBox.appendChild(head);\n\n if (existing && existing.expanded) {\n const targetMeta = BuilderPane.findTable(rel.to);\n const body = el('div', { class: 'with-rel-body' });\n if (!targetMeta) {\n body.appendChild(el('div', { class: 'field-hint', text: 'Unknown target table ' + rel.to }));\n } else {\n // Same controls as the top level, applied to this relation's args —\n // this is the recursive drill-down: pick its fields, filter it, order\n // it, and expand its own relations to arbitrary depth.\n // orderBy/limit only apply to to-many relations; a belongsTo/hasOne\n // always resolves to a single row, so we hide those controls for it.\n const toMany = rel.type === 'hasMany' || rel.type === 'manyToMany';\n body.appendChild(BuilderPane.renderSelectOmit(existing.args, targetMeta));\n body.appendChild(BuilderPane.renderWhere(existing.args, targetMeta, path.concat(rel.name)));\n if (toMany) body.appendChild(BuilderPane.renderOrderLimit(existing.args, targetMeta));\n body.appendChild(BuilderPane.renderWith(existing.args, targetMeta, path.concat(rel.name)));\n }\n relBox.appendChild(body);\n }\n wrap.appendChild(relBox);\n }\n section.appendChild(wrap);\n return section;\n },\n\n renderOrderLimit(args, tableMeta) {\n const section = el('div', { class: 'builder-section' });\n section.appendChild(\n el('div', { class: 'builder-section-title' }, [el('h3', { text: 'orderBy / limit' })])\n );\n\n const row = el('div', { style: { display: 'grid', gridTemplateColumns: '1fr 120px 100px', gap: '8px' } });\n\n const colSel = el('select', { class: 'builder-select' });\n colSel.appendChild(el('option', { value: '', text: '— none —' }));\n for (const c of tableMeta.columns) {\n const opt = el('option', { value: c.name, text: c.name });\n if (args.orderBy && args.orderBy.column === c.name) opt.selected = true;\n colSel.appendChild(opt);\n }\n colSel.addEventListener('change', () => {\n args.orderBy = args.orderBy || { column: null, dir: 'asc' };\n args.orderBy.column = colSel.value || null;\n BuilderPane.updatePreview();\n });\n row.appendChild(colSel);\n\n const dirSel = el('select', { class: 'builder-select' });\n for (const d of ['asc', 'desc']) {\n const opt = el('option', { value: d, text: d });\n if (args.orderBy && args.orderBy.dir === d) opt.selected = true;\n dirSel.appendChild(opt);\n }\n dirSel.addEventListener('change', () => {\n args.orderBy = args.orderBy || { column: null, dir: 'asc' };\n args.orderBy.dir = dirSel.value;\n BuilderPane.updatePreview();\n });\n row.appendChild(dirSel);\n\n const limInput = el('input', {\n class: 'builder-input',\n type: 'number',\n min: '1',\n max: '10000',\n value: args.limit != null ? String(args.limit) : '',\n placeholder: 'limit',\n });\n limInput.addEventListener('input', () => {\n const n = parseInt(limInput.value, 10);\n args.limit = Number.isFinite(n) && n > 0 ? n : null;\n BuilderPane.updatePreview();\n BuilderPane.updateRunButton();\n });\n row.appendChild(limInput);\n section.appendChild(row);\n return section;\n },\n\n renderSelectOmit(args, tableMeta) {\n const section = el('div', { class: 'builder-section' });\n section.appendChild(\n el('div', { class: 'builder-section-title' }, [el('h3', { text: 'select / omit' })])\n );\n const chipsSel = el('div', { class: 'chip-row' });\n chipsSel.appendChild(\n el(\n 'span',\n { style: { fontFamily: 'var(--mono)', fontSize: '10px', color: 'var(--text-muted)', marginRight: '4px' } },\n ['select:']\n )\n );\n for (const c of tableMeta.columns) {\n const active = args.select.includes(c.name);\n chipsSel.appendChild(\n el('button', {\n class: 'chip' + (active ? ' active' : ''),\n text: c.name,\n onclick: () => {\n if (active) args.select = args.select.filter((x) => x !== c.name);\n else {\n args.select.push(c.name);\n args.omit = args.omit.filter((x) => x !== c.name);\n }\n BuilderPane.render();\n },\n })\n );\n }\n section.appendChild(chipsSel);\n\n const chipsOm = el('div', { class: 'chip-row', style: { marginTop: '8px' } });\n chipsOm.appendChild(\n el(\n 'span',\n { style: { fontFamily: 'var(--mono)', fontSize: '10px', color: 'var(--text-muted)', marginRight: '4px' } },\n ['omit:']\n )\n );\n for (const c of tableMeta.columns) {\n const active = args.omit.includes(c.name);\n chipsOm.appendChild(\n el('button', {\n class: 'chip' + (active ? ' active' : ''),\n text: c.name,\n onclick: () => {\n if (active) args.omit = args.omit.filter((x) => x !== c.name);\n else {\n args.omit.push(c.name);\n args.select = args.select.filter((x) => x !== c.name);\n }\n BuilderPane.render();\n },\n })\n );\n }\n section.appendChild(chipsOm);\n return section;\n },\n\n // ---------- Spec assembly / validation ----------\n buildFindManyArgs(localArgs, tableMeta) {\n const out = {};\n const whereObj = BuilderPane.assembleWhere(localArgs, tableMeta);\n if (whereObj) out.where = whereObj;\n if (localArgs.with && localArgs.with.length) {\n out.with = {};\n for (const w of localArgs.with) {\n const rel = (tableMeta.relations || []).find((r) => r.name === w.relation);\n if (!rel) continue;\n const childMeta = BuilderPane.findTable(rel.to);\n const childArgs = childMeta ? BuilderPane.buildFindManyArgs(w.args, childMeta) : null;\n if (childArgs && Object.keys(childArgs).length) {\n out.with[w.relation] = childArgs;\n } else {\n out.with[w.relation] = true;\n }\n }\n }\n if (localArgs.orderBy && localArgs.orderBy.column) {\n out.orderBy = { [localArgs.orderBy.column]: localArgs.orderBy.dir };\n }\n if (localArgs.limit) out.limit = localArgs.limit;\n if (localArgs.select && localArgs.select.length) {\n out.select = Object.fromEntries(localArgs.select.map((c) => [c, true]));\n }\n if (localArgs.omit && localArgs.omit.length) {\n out.omit = Object.fromEntries(localArgs.omit.map((c) => [c, true]));\n }\n return out;\n },\n\n assembleWhere(localArgs, tableMeta) {\n if (!localArgs.where || !localArgs.where.length) return null;\n const clauses = [];\n for (const c of localArgs.where) {\n const col = tableMeta.columns.find((cc) => cc.name === c.column);\n if (!col) continue;\n const parsed = BuilderPane.coerceValue(c.value, col.tsType, c.op);\n if (parsed === undefined) continue;\n const inner =\n c.op === 'equals'\n ? parsed\n : c.insensitive\n ? { [c.op]: parsed, mode: 'insensitive' }\n : { [c.op]: parsed };\n clauses.push({ [c.column]: inner });\n }\n if (!clauses.length) return null;\n if (clauses.length === 1 && localArgs.combinator === 'AND') return clauses[0];\n if (localArgs.combinator === 'AND') return Object.assign({}, ...clauses);\n if (localArgs.combinator === 'OR') return { OR: clauses };\n if (localArgs.combinator === 'NOT') return { NOT: clauses.length === 1 ? clauses[0] : { AND: clauses } };\n return null;\n },\n\n coerceValue(val, tsType, op) {\n if (val == null || val === '') return undefined;\n if (op === 'in' || op === 'notIn') {\n return String(val)\n .split(',')\n .map((s) => s.trim())\n .filter(Boolean)\n .map((s) => (isNumericType(tsType) ? Number(s) : s));\n }\n if (isNumericType(tsType)) {\n const n = Number(val);\n return Number.isFinite(n) ? n : undefined;\n }\n if (isBoolType(tsType)) {\n return val === 'true' || val === true;\n }\n return String(val);\n },\n\n validate() {\n const errs = {};\n if (!State.builder.table) errs.table = 'Pick a table';\n if (State.builder.args.limit != null && State.builder.args.limit < 1) {\n errs.limit = 'Limit must be ≥ 1';\n }\n for (const c of State.builder.args.where) {\n if (!c.column) errs.where = 'Clause missing column';\n if (c.value === '' || c.value == null) errs.where = 'Clause missing value';\n }\n State.builder.validation = errs;\n return Object.keys(errs).length === 0;\n },\n\n updateRunButton() {\n const ok = BuilderPane.validate();\n $('#builderRun').disabled = !ok;\n },\n\n updatePreview() {\n const host = $('#builderPreview');\n const tableMeta = BuilderPane.findTable(State.builder.table);\n if (!tableMeta) {\n host.textContent = '';\n host.dataset.rawCode = '';\n return;\n }\n const args = BuilderPane.buildFindManyArgs(State.builder.args, tableMeta);\n const code = BuilderPane.renderCode(State.builder.table, args);\n host.dataset.rawCode = code;\n host.innerHTML = highlightTs(code);\n },\n\n renderCode(tableName, args) {\n const ind = ' ';\n let out = 'await client.' + tableName + '.findMany(';\n const body = renderJsArgs(args, 1);\n if (!body) {\n out += ');';\n } else {\n out += '{\\n' + body + '\\n});';\n }\n return out;\n },\n\n async run() {\n if (!BuilderPane.validate()) return;\n const tableMeta = BuilderPane.findTable(State.builder.table);\n const args = BuilderPane.buildFindManyArgs(State.builder.args, tableMeta);\n State.builder.running = true;\n State.builder.error = null;\n BuilderPane.renderResults();\n try {\n const res = await Api.runBuilder(State.builder.table, args);\n State.builder.result = res;\n State.builder.error = null;\n } catch (err) {\n State.builder.error = err.message;\n State.builder.result = null;\n } finally {\n State.builder.running = false;\n BuilderPane.renderResults();\n }\n },\n\n renderResults() {\n const host = $('#builderResults');\n const header = $('#builderResultsHeader');\n if (State.builder.running) {\n header.textContent = 'Results · running…';\n host.innerHTML = '';\n const skel = el('div');\n for (let i = 0; i < 6; i++) {\n const row = el('div', { class: 'skeleton-row' });\n for (let c = 0; c < 4; c++) row.appendChild(el('div', { class: 'skeleton-cell' }));\n skel.appendChild(row);\n }\n host.appendChild(skel);\n return;\n }\n if (State.builder.error) {\n header.textContent = 'Results · error';\n host.innerHTML = '';\n host.appendChild(\n el('div', { class: 'error-box' }, [\n el('div', { class: 'error-box-title', text: 'Builder error' }),\n document.createTextNode(State.builder.error),\n ])\n );\n return;\n }\n const r = State.builder.result;\n if (!r) {\n header.textContent = 'Results';\n host.innerHTML = '<div class=\"empty\">Run to see results.</div>';\n return;\n }\n header.textContent = 'Results · ' + formatNumber(r.rowCount) + ' rows · ' + r.elapsedMs + 'ms';\n host.innerHTML = '';\n if (!r.rows || !r.rows.length) {\n host.appendChild(el('div', { class: 'empty', text: 'No rows.' }));\n return;\n }\n const cols = (r.columns || []).map((c) => c.name || c);\n const rendered = cols.length ? cols : Object.keys(r.rows[0] || {});\n host.appendChild(Table.render(rendered, r.rows));\n },\n\n openSave() {\n if (!BuilderPane.validate()) return;\n const tableMeta = BuilderPane.findTable(State.builder.table);\n const args = BuilderPane.buildFindManyArgs(State.builder.args, tableMeta);\n State.savePrompt = { kind: 'builder', args, fixedTable: State.builder.table };\n Modal.open('savePromptBackdrop');\n $('#savePromptName').value = '';\n SavePrompt.fillTables(State.builder.table);\n },\n\n bind() {\n $('#builderRun').addEventListener('click', () => BuilderPane.run());\n $('#builderSave').addEventListener('click', () => BuilderPane.openSave());\n $('#builderCopy').addEventListener('click', () => {\n const code = $('#builderPreview').dataset.rawCode || '';\n copyToClipboard(code, 'TypeScript copied');\n });\n $('#builderReset').addEventListener('click', () => {\n if (State.builder.table) BuilderPane.resetForTable(State.builder.table);\n });\n },\n };\n\n // ---------- JS args renderer (used by builder preview) ----------\n function renderJsArgs(obj, depth) {\n if (obj == null) return '';\n const ind = ' '.repeat(depth);\n const indIn = ' '.repeat(depth + 1);\n const keys = Object.keys(obj);\n if (!keys.length) return '';\n const lines = [];\n for (const k of keys) {\n const v = obj[k];\n lines.push(ind + k + ': ' + renderJsValue(v, depth) + ',');\n }\n return lines.join('\\n');\n }\n function renderJsValue(v, depth) {\n if (v === null) return 'null';\n if (v === undefined) return 'undefined';\n if (typeof v === 'boolean') return v ? 'true' : 'false';\n if (typeof v === 'number') return String(v);\n if (typeof v === 'string') return \"'\" + v.replace(/\\\\/g, '\\\\\\\\').replace(/'/g, \"\\\\'\") + \"'\";\n if (Array.isArray(v)) {\n if (!v.length) return '[]';\n return '[' + v.map((x) => renderJsValue(x, depth)).join(', ') + ']';\n }\n if (typeof v === 'object') {\n const keys = Object.keys(v);\n if (!keys.length) return '{}';\n const ind = ' '.repeat(depth + 1);\n const close = ' '.repeat(depth);\n const inner = keys.map((k) => ind + k + ': ' + renderJsValue(v[k], depth + 1)).join(',\\n');\n return '{\\n' + inner + '\\n' + close + '}';\n }\n return String(v);\n }\n\n // ---------- TS syntax highlighter (simple tokenizer) ----------\n function highlightTs(src) {\n const KWS = new Set([\n 'await',\n 'async',\n 'const',\n 'let',\n 'var',\n 'return',\n 'if',\n 'else',\n 'true',\n 'false',\n 'null',\n 'undefined',\n 'import',\n 'from',\n 'export',\n 'new',\n ]);\n const out = [];\n let i = 0;\n while (i < src.length) {\n const ch = src[i];\n // comment\n if (ch === '/' && src[i + 1] === '/') {\n let j = i;\n while (j < src.length && src[j] !== '\\n') j++;\n out.push('<span class=\"tok-comment\">' + escapeHtml(src.slice(i, j)) + '</span>');\n i = j;\n continue;\n }\n // string\n if (ch === \"'\" || ch === '\"' || ch === '`') {\n const q = ch;\n let j = i + 1;\n while (j < src.length && src[j] !== q) {\n if (src[j] === '\\\\') j += 2;\n else j++;\n }\n j = Math.min(src.length, j + 1);\n out.push('<span class=\"tok-str\">' + escapeHtml(src.slice(i, j)) + '</span>');\n i = j;\n continue;\n }\n // number\n if (/[0-9]/.test(ch)) {\n let j = i;\n while (j < src.length && /[0-9_.]/.test(src[j])) j++;\n out.push('<span class=\"tok-num\">' + escapeHtml(src.slice(i, j)) + '</span>');\n i = j;\n continue;\n }\n // identifier / keyword\n if (/[A-Za-z_$]/.test(ch)) {\n let j = i;\n while (j < src.length && /[A-Za-z0-9_$]/.test(src[j])) j++;\n const word = src.slice(i, j);\n // key: identifier followed by colon\n let k = j;\n while (k < src.length && src[k] === ' ') k++;\n if (src[k] === ':') {\n out.push('<span class=\"tok-key\">' + escapeHtml(word) + '</span>');\n } else if (KWS.has(word)) {\n out.push('<span class=\"tok-kw\">' + escapeHtml(word) + '</span>');\n } else if (src[j] === '(' || (src[j] === '.' && false)) {\n out.push('<span class=\"tok-fn\">' + escapeHtml(word) + '</span>');\n } else {\n out.push(escapeHtml(word));\n }\n i = j;\n continue;\n }\n // punctuation\n if (/[{}[\\](),;:]/.test(ch)) {\n out.push('<span class=\"tok-punc\">' + escapeHtml(ch) + '</span>');\n i++;\n continue;\n }\n out.push(escapeHtml(ch));\n i++;\n }\n return out.join('');\n }\n\n // =====================================================================\n // Saved queries\n // =====================================================================\n const SavedQueries = {\n async load(q) {\n if (q.kind === 'builder') {\n if (q.table) {\n const t = State.tablesByName.get(q.table);\n if (t) {\n State.currentTable = t;\n Storage.set('currentTable', t.name);\n }\n }\n Router.selectTab('builder');\n // Re-hydrating from a saved args object is tricky since our internal\n // shape differs from the flat Turbine args object. For now we store\n // the args alongside the builder table and render a hint.\n State.builder.table = q.table;\n State.builder.args = SavedQueries.hydrateBuilderArgs(q.args || {}, q.table);\n BuilderPane.render();\n toast('Loaded saved builder: ' + q.name, 'ok');\n }\n },\n\n hydrateBuilderArgs(argsObj, tableName) {\n // Best-effort inverse of buildFindManyArgs for a flat where obj\n const tableMeta = State.tablesByName.get(tableName);\n const out = emptyBuilderArgs();\n if (!tableMeta) return out;\n if (argsObj.limit) out.limit = argsObj.limit;\n if (argsObj.orderBy) {\n const k = Object.keys(argsObj.orderBy)[0];\n if (k) out.orderBy = { column: k, dir: argsObj.orderBy[k] };\n }\n if (argsObj.where) {\n const w = argsObj.where;\n const collectInto = (obj, combinator) => {\n for (const [col, val] of Object.entries(obj)) {\n if (col === 'AND' || col === 'OR' || col === 'NOT') continue;\n if (val && typeof val === 'object' && !Array.isArray(val)) {\n const op = Object.keys(val).find((k) => k !== 'mode') || 'equals';\n out.where.push({\n column: col,\n op,\n value: val[op],\n insensitive: val.mode === 'insensitive',\n });\n } else {\n out.where.push({ column: col, op: 'equals', value: val, insensitive: false });\n }\n }\n };\n if (w.OR) {\n out.combinator = 'OR';\n for (const c of w.OR) collectInto(c, 'OR');\n } else if (w.AND) {\n for (const c of w.AND) collectInto(c, 'AND');\n } else {\n collectInto(w, 'AND');\n }\n }\n if (argsObj.select) out.select = Object.keys(argsObj.select);\n if (argsObj.omit) out.omit = Object.keys(argsObj.omit);\n if (argsObj.with) {\n for (const [relName, v] of Object.entries(argsObj.with)) {\n const rel = (tableMeta.relations || []).find((r) => r.name === relName);\n if (!rel) continue;\n const childArgs = v === true ? emptyBuilderArgs() : SavedQueries.hydrateBuilderArgs(v, rel.to);\n out.with.push({ relation: relName, expanded: true, args: childArgs });\n }\n }\n return out;\n },\n\n async remove(id) {\n try {\n await Api.deleteSaved(id);\n State.savedQueries = State.savedQueries.filter((q) => q.id !== id);\n Sidebar.renderSaved();\n toast('Deleted saved query', 'ok');\n } catch (err) {\n toast('Delete failed: ' + err.message, 'err');\n }\n },\n\n async refresh() {\n try {\n const res = await Api.savedQueries();\n State.savedQueries = res.queries || [];\n Sidebar.renderSaved();\n } catch {\n // silent on refresh fail\n }\n },\n };\n\n // =====================================================================\n // Modals\n // =====================================================================\n const Modal = {\n open(id) {\n const m = document.getElementById(id);\n if (!m) return;\n m.classList.add('open');\n m.setAttribute('aria-hidden', 'false');\n setTimeout(() => {\n const inp = m.querySelector('input,textarea,select,button');\n if (inp) inp.focus();\n }, 20);\n },\n close(id) {\n const m = document.getElementById(id);\n if (!m) return;\n m.classList.remove('open');\n m.setAttribute('aria-hidden', 'true');\n },\n bind() {\n $$('[data-close-modal]').forEach((b) => {\n b.addEventListener('click', () => Modal.close(b.dataset.closeModal));\n });\n $$('.modal-backdrop').forEach((bd) => {\n bd.addEventListener('click', (e) => {\n if (e.target === bd) Modal.close(bd.id);\n });\n });\n },\n };\n\n const JsonModal = {\n open(val) {\n const host = $('#jsonModalBody');\n host.textContent = JSON.stringify(val, null, 2);\n Modal.open('jsonModalBackdrop');\n },\n };\n\n // =====================================================================\n // Row editor (write mode): single-row insert / edit\n // =====================================================================\n // Matches the server's redaction marker so a redacted PII cell is never\n // written back verbatim when editing a row.\n const REDACTED_MARKER = '•• redacted ••';\n\n function coerceInput(col, raw) {\n const ts = col.tsType || '';\n if (ts === 'number' || ts === 'bigint') {\n const n = Number(raw);\n return Number.isNaN(n) ? raw : n;\n }\n if (ts === 'boolean') return raw === 'true' || raw === '1' || raw === 't';\n return raw;\n }\n\n const RowEditor = {\n mode: 'insert',\n table: null,\n pkWhere: null,\n inputs: [],\n\n openInsert() {\n const t = State.currentTable;\n if (!t) {\n toast('Select a table first', 'err');\n return;\n }\n RowEditor.mode = 'insert';\n RowEditor.table = t;\n RowEditor.pkWhere = null;\n $('#rowEditorTitle').textContent = 'Insert into ' + t.name;\n $('#rowEditorHint').textContent = 'Leave a field blank to use its database default.';\n RowEditor.buildFields(t, {});\n RowEditor.showError(null);\n Modal.open('rowEditorBackdrop');\n },\n\n openEdit(row) {\n const t = State.currentTable;\n if (!t) return;\n const pks = t.primaryKey || [];\n if (!pks.length) {\n toast('Table has no primary key; cannot edit a single row', 'err');\n return;\n }\n RowEditor.mode = 'edit';\n RowEditor.table = t;\n const where = {};\n for (const pk of pks) where[pk] = row[pk];\n RowEditor.pkWhere = where;\n $('#rowEditorTitle').textContent = 'Edit row in ' + t.name;\n $('#rowEditorHint').textContent = 'Primary key columns are read-only. Cleared fields are left unchanged.';\n RowEditor.buildFields(t, row);\n RowEditor.showError(null);\n Modal.open('rowEditorBackdrop');\n },\n\n buildFields(t, row) {\n const host = $('#rowEditorFields');\n host.innerHTML = '';\n RowEditor.inputs = [];\n for (const c of t.columns) {\n const field = el('div', { class: 'row-editor-field' });\n const label = el('label');\n label.appendChild(document.createTextNode(c.name + ' '));\n label.appendChild(el('span', { class: 'col-pg', text: c.pgType || '' }));\n if (c.isPrimaryKey) label.appendChild(el('span', { class: 'pkflag', text: ' · PK' }));\n if (c.pii) label.appendChild(el('span', { class: 'piiflag', text: ' · PII' }));\n const input = el('input', { type: 'text' });\n const v = row[c.name];\n if (RowEditor.mode === 'edit' && v !== undefined && v !== null && v !== REDACTED_MARKER) {\n input.value = typeof v === 'object' ? JSON.stringify(v) : String(v);\n } else if (RowEditor.mode === 'edit' && v === REDACTED_MARKER) {\n input.placeholder = 'redacted · leave blank to keep the existing value';\n }\n if (RowEditor.mode === 'edit' && c.isPrimaryKey) input.disabled = true;\n field.appendChild(label);\n\n const rec = { col: c, input, isNull: false };\n // Nullable, non-PK columns get an explicit NULL toggle. Toggling it\n // disables the text input and makes collect() emit an explicit null\n // (which the write routes send as a $N param), rather than the\n // blank-means-unchanged/default semantics of an empty input.\n const nullable = c.nullable && !c.isPrimaryKey;\n if (nullable) {\n const basePlaceholder = input.placeholder;\n const nullBtn = el('button', {\n type: 'button',\n class: 'null-toggle',\n text: 'NULL',\n title: 'Set this column to NULL',\n 'aria-pressed': 'false',\n });\n nullBtn.addEventListener('click', () => {\n rec.isNull = !rec.isNull;\n nullBtn.classList.toggle('active', rec.isNull);\n nullBtn.setAttribute('aria-pressed', rec.isNull ? 'true' : 'false');\n input.disabled = rec.isNull;\n input.classList.toggle('null-set', rec.isNull);\n input.placeholder = rec.isNull ? 'NULL' : basePlaceholder;\n });\n const inputRow = el('div', { class: 'row-editor-input-row' });\n inputRow.appendChild(input);\n inputRow.appendChild(nullBtn);\n field.appendChild(inputRow);\n } else {\n field.appendChild(input);\n }\n\n host.appendChild(field);\n RowEditor.inputs.push(rec);\n }\n },\n\n collect() {\n const data = {};\n for (const rec of RowEditor.inputs) {\n const { col, input } = rec;\n if (RowEditor.mode === 'edit' && col.isPrimaryKey) continue;\n // An explicit NULL toggle wins over any typed value (including a\n // redacted PII placeholder): send a real null, never the marker.\n if (rec.isNull) {\n data[col.name] = null;\n continue;\n }\n if (input.disabled) continue;\n const raw = input.value;\n if (raw === '') continue;\n data[col.name] = coerceInput(col, raw);\n }\n return data;\n },\n\n async confirm() {\n const t = RowEditor.table;\n if (!t) return;\n const data = RowEditor.collect();\n if (!Object.keys(data).length) {\n RowEditor.showError(RowEditor.mode === 'insert' ? 'Enter at least one value.' : 'No changes to save.');\n return;\n }\n try {\n if (RowEditor.mode === 'insert') {\n await Api.rowInsert(t.name, data);\n toast('Row inserted', 'ok');\n } else {\n await Api.rowUpdate(t.name, RowEditor.pkWhere, data);\n toast('Row updated', 'ok');\n }\n Modal.close('rowEditorBackdrop');\n DataPane.loadAndRender();\n } catch (err) {\n RowEditor.showError(err && err.message ? err.message : 'Write failed');\n }\n },\n\n showError(msg) {\n const box = $('#rowEditorError');\n if (!msg) {\n box.hidden = true;\n box.textContent = '';\n return;\n }\n box.hidden = false;\n box.textContent = msg;\n },\n\n bind() {\n $('#rowEditorConfirm').addEventListener('click', () => RowEditor.confirm());\n },\n };\n\n const SavePrompt = {\n fillTables(preferred) {\n const sel = $('#savePromptTable');\n sel.innerHTML = '';\n for (const t of State.tables) {\n const opt = el('option', { value: t.name, text: t.name });\n sel.appendChild(opt);\n }\n const target =\n preferred ||\n (State.currentTable && State.currentTable.name) ||\n (State.tables[0] && State.tables[0].name);\n if (target) sel.value = target;\n },\n bind() {\n $('#savePromptConfirm').addEventListener('click', async () => {\n const name = $('#savePromptName').value.trim();\n const table = $('#savePromptTable').value;\n if (!name || !table) {\n toast('Name and table required', 'err');\n return;\n }\n const payload = {\n table,\n name,\n kind: 'builder',\n args: State.savePrompt.args,\n };\n try {\n await Api.saveQuery(payload);\n Modal.close('savePromptBackdrop');\n toast('Query saved', 'ok');\n SavedQueries.refresh();\n } catch (err) {\n toast('Save failed: ' + err.message, 'err');\n }\n });\n },\n };\n\n // =====================================================================\n // Command palette\n // =====================================================================\n const Palette = {\n bind() {\n $('#openPalette').addEventListener('click', () => Palette.open());\n const input = $('#paletteInput');\n input.addEventListener('input', () => Palette.render());\n input.addEventListener('keydown', (e) => {\n if (e.key === 'Escape') {\n Modal.close('paletteBackdrop');\n return;\n }\n if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {\n e.preventDefault();\n const items = $$('.palette-item');\n if (!items.length) return;\n const cur = items.findIndex((n) => n.classList.contains('selected'));\n const next =\n e.key === 'ArrowDown'\n ? Math.min(items.length - 1, cur + 1)\n : Math.max(0, cur - 1);\n items.forEach((n, idx) => n.classList.toggle('selected', idx === next));\n items[next].scrollIntoView({ block: 'nearest' });\n }\n if (e.key === 'Enter') {\n const sel = $('.palette-item.selected');\n if (sel) sel.click();\n }\n });\n },\n\n open() {\n Modal.open('paletteBackdrop');\n $('#paletteInput').value = '';\n Palette.render();\n },\n\n render() {\n const q = $('#paletteInput').value.toLowerCase().trim();\n const list = $('#paletteList');\n list.innerHTML = '';\n\n const items = [];\n\n // Commands\n const cmds = [\n { name: 'Go to Query tab', kbd: ['G', 'Q'], run: () => Router.selectTab('builder') },\n { name: 'Go to Data tab', kbd: ['G', 'D'], run: () => Router.selectTab('data') },\n { name: 'Go to Schema tab', kbd: ['G', 'S'], run: () => Router.selectTab('schema') },\n { name: 'Run query', kbd: [isMac() ? '⌘' : 'Ctrl', '↵'], run: () => { Router.selectTab('builder'); setTimeout(() => BuilderPane.run(), 50); } },\n { name: 'Save query', kbd: [isMac() ? '⌘' : 'Ctrl', 'S'], run: () => { Router.selectTab('builder'); BuilderPane.openSave(); } },\n { name: 'Refresh data', kbd: ['R'], run: () => DataPane.loadAndRender() },\n { name: 'Reload schema', kbd: ['Shift', 'R'], run: () => boot(true) },\n ];\n const filteredCmds = q\n ? cmds.filter((c) => c.name.toLowerCase().includes(q))\n : cmds;\n if (filteredCmds.length) {\n list.appendChild(el('div', { class: 'palette-section', text: 'Commands' }));\n for (const c of filteredCmds) items.push(Palette.renderItem(c.name, c.kbd, c.run));\n }\n\n // Tables\n const tables = State.tables.filter(\n (t) => !q || t.name.toLowerCase().includes(q)\n );\n if (tables.length) {\n list.appendChild(el('div', { class: 'palette-section', text: 'Tables' }));\n for (const t of tables.slice(0, 50)) {\n items.push(\n Palette.renderItem(t.name, [formatCount(t.estimatedRows)], () => {\n Router.selectTable(t.name);\n Modal.close('paletteBackdrop');\n })\n );\n }\n }\n\n if (!items.length) {\n list.appendChild(el('div', { class: 'empty', style: { padding: '24px' }, text: 'No matches.' }));\n }\n for (const it of items) list.appendChild(it);\n const first = list.querySelector('.palette-item');\n if (first) first.classList.add('selected');\n },\n\n renderItem(name, kbd, run) {\n const kbdEls = (kbd || []).map((k) => el('span', { class: 'kbd', text: k }));\n const item = el('div', { class: 'palette-item' }, [\n el('div', { class: 'pi-name' }, [\n el(\n 'svg',\n {\n class: 'pi-icon',\n viewBox: '0 0 24 24',\n fill: 'none',\n stroke: 'currentColor',\n 'stroke-width': '2',\n },\n []\n ),\n el('span', { text: name }),\n ]),\n el('div', { class: 'pi-kbd' }, kbdEls),\n ]);\n item.addEventListener('click', () => {\n run();\n Modal.close('paletteBackdrop');\n });\n return item;\n },\n };\n\n // =====================================================================\n // Keyboard shortcuts\n // =====================================================================\n function bindKeys() {\n document.addEventListener('keydown', (e) => {\n const cmd = e.metaKey || e.ctrlKey;\n // Cmd+K opens palette\n if (cmd && (e.key === 'k' || e.key === 'K')) {\n e.preventDefault();\n Palette.open();\n return;\n }\n if (e.key === 'Escape') {\n ['savePromptBackdrop', 'jsonModalBackdrop', 'paletteBackdrop', 'rowEditorBackdrop'].forEach((id) =>\n Modal.close(id)\n );\n return;\n }\n if (isTypingInInput()) return;\n if (e.key === '/') {\n e.preventDefault();\n $('#sidebarSearch').focus();\n }\n if (e.key === '?') {\n Palette.open();\n }\n });\n }\n\n // =====================================================================\n // Init / boot\n // =====================================================================\n async function boot(reloadOnly) {\n try {\n const schema = await Api.schema();\n State.schema = schema;\n State.tables = schema.tables || [];\n State.tablesByName = new Map(State.tables.map((t) => [t.name, t]));\n State.enums = schema.enums || {};\n State.writable = schema.writable === true;\n State.showPii = schema.showPii === true;\n State.connOk = true;\n // Persistent write-mode affordances (only when the server allows writes).\n $('#writeBanner').hidden = !State.writable;\n $('#dataInsert').hidden = !State.writable;\n // Persistent unredacted-PII indicator (only under --show-pii).\n $('#piiBanner').hidden = !State.showPii;\n $('#connDot').classList.add('ok');\n $('#connLabel').textContent = 'connected';\n const meta =\n (schema.schema || 'public') + ' · ' + State.tables.length + ' tables';\n $('#headerMeta').textContent = meta;\n $('#sidebarMeta').textContent = meta;\n\n // Restore\n const savedW = Storage.get('sidebarW');\n if (savedW) document.documentElement.style.setProperty('--sidebar-w', savedW);\n const savedTab = Storage.get('tab', 'builder');\n const savedTable = Storage.get('currentTable');\n const initialTable = (savedTable && State.tablesByName.has(savedTable)\n ? savedTable\n : State.tables[0] && State.tables[0].name);\n if (initialTable) {\n State.currentTable = State.tablesByName.get(initialTable);\n }\n\n Sidebar.render();\n Router.selectTab(savedTab || 'builder');\n if (State.currentTable) {\n if (savedTab === 'data') DataPane.loadAndRender();\n if (savedTab === 'schema') SchemaPane.render();\n if (savedTab === 'builder' || !savedTab) BuilderPane.resetForTable(State.currentTable.name);\n }\n\n SavedQueries.refresh();\n } catch (err) {\n State.connOk = false;\n $('#connDot').classList.remove('ok');\n $('#connDot').classList.add('err');\n $('#connLabel').textContent = 'offline';\n $('#headerMeta').textContent = 'connection failed';\n $('#sidebarMeta').textContent = '—';\n const mainErr = el('div', { class: 'error-box' }, [\n el('div', { class: 'error-box-title', text: 'Cannot reach /api/schema' }),\n document.createTextNode(err.message),\n ]);\n $('#dataScroll').innerHTML = '';\n $('#dataScroll').appendChild(mainErr);\n }\n }\n\n function init() {\n Sidebar.bind();\n Router.bind();\n DataPane.bind();\n BuilderPane.bind();\n Modal.bind();\n SavePrompt.bind();\n RowEditor.bind();\n Palette.bind();\n bindKeys();\n boot();\n }\n\n document.addEventListener('DOMContentLoaded', init);\n </script>\n</body>\n</html>\n";
|
|
6
|
+
exports.STUDIO_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <meta name=\"color-scheme\" content=\"dark\" />\n <title>Turbine Studio</title>\n <!-- No external font fetch: Studio is loopback-only with a strict CSP\n (font-src 'self'), so we rely on the system font stack defined in\n --sans / --mono below. Keeps Studio fully offline + CSP-clean. -->\n <style>\n /* =====================================================================\n Design tokens\n ===================================================================== */\n :root {\n --bg: #0a0a0b;\n --bg-elev: #111113;\n --bg-hover: #1a1a1d;\n --bg-active: #22222a;\n --border: #26262b;\n --border-strong: #3a3a42;\n --text: #e6e6ea;\n --text-dim: #8a8a93;\n --text-muted: #5a5a63;\n --accent: #60a5fa;\n --accent-hover: #93c5fd;\n --accent-dim: rgba(96, 165, 250, 0.1);\n --green: #4ade80;\n --orange: #fb923c;\n --red: #f87171;\n --yellow: #facc15;\n --purple: #a78bfa;\n --mono: \"Geist Mono\", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;\n --sans: Inter, system-ui, -apple-system, \"Segoe UI\", sans-serif;\n --ease: cubic-bezier(0.4, 0, 0.2, 1);\n --t: 150ms var(--ease);\n --radius: 6px;\n --radius-lg: 10px;\n --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.6), 0 2px 6px rgba(0, 0, 0, 0.4);\n --header-h: 48px;\n --sidebar-w: 280px;\n }\n\n /* =====================================================================\n Write mode (opt-in) + PII\n ===================================================================== */\n .write-banner {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 6px 14px;\n background: rgba(248, 113, 113, 0.14);\n border-bottom: 1px solid var(--red);\n color: var(--red);\n font-size: 12px;\n letter-spacing: 0.04em;\n flex: 0 0 auto;\n }\n .write-banner strong {\n font-weight: 700;\n }\n .write-banner span:last-child {\n color: var(--text-dim);\n }\n .write-banner-dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n background: var(--red);\n box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.2);\n }\n .pii-banner {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 6px 14px;\n background: rgba(250, 204, 21, 0.12);\n border-bottom: 1px solid rgba(250, 204, 21, 0.5);\n color: var(--yellow);\n font-size: 12px;\n letter-spacing: 0.04em;\n flex: 0 0 auto;\n }\n .pii-banner strong {\n font-weight: 700;\n }\n .pii-banner span:last-child {\n color: var(--text-dim);\n }\n /* Demo banner + live mode switcher (shown only in --demo mode). Distinct\n blue accent, separate from the red write and yellow PII banners. */\n .demo-banner {\n display: flex;\n align-items: center;\n gap: 12px;\n flex-wrap: wrap;\n padding: 6px 14px;\n background: rgba(96, 165, 250, 0.12);\n border-bottom: 1px solid rgba(96, 165, 250, 0.5);\n color: var(--accent);\n font-size: 12px;\n letter-spacing: 0.02em;\n flex: 0 0 auto;\n }\n .demo-banner strong {\n font-weight: 700;\n }\n .demo-banner .demo-msg {\n color: var(--text-dim);\n }\n .demo-switcher {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-left: auto;\n }\n .demo-toggle {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n background: var(--bg-hover);\n border: 1px solid var(--border-strong);\n color: var(--text-dim);\n border-radius: 999px;\n padding: 3px 12px;\n font-size: 11px;\n letter-spacing: 0.03em;\n cursor: pointer;\n transition: var(--t);\n white-space: nowrap;\n }\n .demo-toggle:hover {\n color: var(--text);\n border-color: var(--accent);\n }\n .demo-toggle-dot {\n width: 7px;\n height: 7px;\n border-radius: 50%;\n background: var(--text-muted);\n transition: var(--t);\n }\n .demo-toggle[aria-pressed=\"true\"] {\n color: var(--accent);\n border-color: var(--accent);\n background: var(--accent-dim);\n }\n .demo-toggle[aria-pressed=\"true\"] .demo-toggle-dot {\n background: var(--accent);\n box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2);\n }\n .badge.pii {\n background: rgba(250, 204, 21, 0.14);\n color: var(--yellow);\n border: 1px solid rgba(250, 204, 21, 0.3);\n }\n .row-actions {\n display: flex;\n gap: 4px;\n white-space: nowrap;\n }\n .row-action-btn {\n background: transparent;\n border: 1px solid var(--border);\n color: var(--text-dim);\n border-radius: var(--radius);\n padding: 2px 8px;\n font-size: 11px;\n cursor: pointer;\n transition: var(--t);\n }\n .row-action-btn:hover {\n background: var(--bg-hover);\n color: var(--text);\n }\n .row-action-btn.danger:hover {\n border-color: var(--red);\n color: var(--red);\n }\n .row-editor-hint {\n color: var(--text-dim);\n font-size: 12px;\n margin-bottom: 12px;\n }\n .row-editor-fields {\n display: flex;\n flex-direction: column;\n gap: 10px;\n max-height: 52vh;\n overflow-y: auto;\n }\n .row-editor-field {\n display: flex;\n flex-direction: column;\n gap: 4px;\n }\n .row-editor-field label {\n font-size: 11px;\n color: var(--text-dim);\n font-family: var(--mono);\n }\n .row-editor-field label .pkflag {\n color: var(--accent);\n }\n .row-editor-field label .piiflag {\n color: var(--yellow);\n }\n .row-editor-field input,\n .row-editor-field select {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n color: var(--text);\n padding: 6px 8px;\n font-family: var(--mono);\n font-size: 12px;\n }\n .row-editor-field input:disabled,\n .row-editor-field select:disabled {\n opacity: 0.55;\n }\n .row-editor-input-row {\n display: flex;\n align-items: stretch;\n gap: 6px;\n }\n .row-editor-input-row input,\n .row-editor-input-row select {\n flex: 1 1 auto;\n min-width: 0;\n }\n .row-editor-field input.null-set,\n .row-editor-field select.null-set {\n opacity: 0.5;\n font-style: italic;\n color: var(--text-dim);\n }\n .null-toggle {\n flex: 0 0 auto;\n background: transparent;\n border: 1px solid var(--border);\n color: var(--text-dim);\n border-radius: var(--radius);\n padding: 2px 10px;\n font-family: var(--mono);\n font-size: 11px;\n letter-spacing: 0.04em;\n cursor: pointer;\n transition: var(--t);\n }\n .null-toggle:hover {\n background: var(--bg-hover);\n color: var(--text);\n }\n .null-toggle.active {\n background: var(--accent-dim);\n border-color: var(--accent);\n color: var(--accent);\n }\n .row-editor-error {\n margin-top: 12px;\n padding: 8px 10px;\n background: rgba(248, 113, 113, 0.1);\n border: 1px solid var(--red);\n border-radius: var(--radius);\n color: var(--red);\n font-size: 12px;\n white-space: pre-wrap;\n }\n\n /* =====================================================================\n Reset + base\n ===================================================================== */\n *,\n *::before,\n *::after {\n box-sizing: border-box;\n }\n html,\n body {\n margin: 0;\n padding: 0;\n height: 100%;\n background: var(--bg);\n color: var(--text);\n font-family: var(--sans);\n font-size: 13px;\n line-height: 1.5;\n -webkit-font-smoothing: antialiased;\n text-rendering: optimizeLegibility;\n }\n body {\n overflow: hidden;\n }\n button {\n font-family: inherit;\n font-size: inherit;\n color: inherit;\n background: none;\n border: none;\n cursor: pointer;\n padding: 0;\n }\n input,\n textarea,\n select {\n font-family: inherit;\n font-size: inherit;\n color: inherit;\n }\n ::selection {\n background: var(--accent-dim);\n color: var(--text);\n }\n ::-webkit-scrollbar {\n width: 10px;\n height: 10px;\n }\n ::-webkit-scrollbar-track {\n background: transparent;\n }\n ::-webkit-scrollbar-thumb {\n background: #2a2a30;\n border-radius: 10px;\n border: 2px solid var(--bg);\n }\n ::-webkit-scrollbar-thumb:hover {\n background: #3a3a42;\n }\n :focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n border-radius: 3px;\n }\n a {\n color: var(--accent);\n text-decoration: none;\n }\n a:hover {\n color: var(--accent-hover);\n }\n\n /* =====================================================================\n App shell\n ===================================================================== */\n .app {\n display: grid;\n grid-template-rows: var(--header-h) 1fr;\n height: 100vh;\n width: 100vw;\n }\n\n /* Header */\n .header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 0 16px;\n border-bottom: 1px solid var(--border);\n background: var(--bg-elev);\n height: var(--header-h);\n user-select: none;\n }\n .brand {\n display: flex;\n align-items: center;\n gap: 10px;\n font-family: var(--mono);\n font-size: 13px;\n font-weight: 600;\n letter-spacing: -0.01em;\n }\n .brand-mark {\n width: 18px;\n height: 18px;\n border-radius: 4px;\n background: linear-gradient(135deg, var(--accent) 0%, var(--purple) 100%);\n position: relative;\n }\n .brand-mark::after {\n content: \"\";\n position: absolute;\n inset: 4px;\n border-radius: 2px;\n background: var(--bg-elev);\n }\n .brand-dot {\n color: var(--text-muted);\n }\n .brand-sub {\n color: var(--text-dim);\n font-weight: 400;\n }\n .header-right {\n display: flex;\n align-items: center;\n gap: 14px;\n font-size: 12px;\n color: var(--text-dim);\n }\n .header-meta {\n font-family: var(--mono);\n font-size: 11.5px;\n }\n .header-meta .sep {\n color: var(--text-muted);\n margin: 0 6px;\n }\n .conn {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n color: var(--text-dim);\n }\n .conn-dot {\n width: 7px;\n height: 7px;\n border-radius: 50%;\n background: var(--text-muted);\n transition: background var(--t), box-shadow var(--t);\n }\n .conn-dot.ok {\n background: var(--green);\n box-shadow: 0 0 0 3px rgba(74, 222, 128, 0.15);\n }\n .conn-dot.err {\n background: var(--red);\n box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.15);\n }\n .kbd {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n min-width: 18px;\n height: 18px;\n padding: 0 5px;\n border: 1px solid var(--border-strong);\n border-bottom-width: 2px;\n border-radius: 4px;\n background: var(--bg-elev);\n font-family: var(--mono);\n font-size: 10.5px;\n color: var(--text-dim);\n }\n .header-cmd {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 4px 8px 4px 10px;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: var(--bg);\n color: var(--text-dim);\n font-size: 12px;\n transition: border-color var(--t), color var(--t);\n }\n .header-cmd:hover {\n border-color: var(--border-strong);\n color: var(--text);\n }\n .icon-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n border-radius: var(--radius);\n color: var(--text-dim);\n transition: background var(--t), color var(--t);\n }\n .icon-btn:hover {\n background: var(--bg-hover);\n color: var(--text);\n }\n .hamburger {\n display: none;\n }\n\n /* Body layout */\n .body {\n display: grid;\n grid-template-columns: var(--sidebar-w) 1fr;\n min-height: 0;\n overflow: hidden;\n position: relative;\n }\n\n /* =====================================================================\n Sidebar\n ===================================================================== */\n .sidebar {\n display: flex;\n flex-direction: column;\n border-right: 1px solid var(--border);\n background: var(--bg-elev);\n min-width: 0;\n position: relative;\n }\n .sidebar-header {\n padding: 14px 16px 10px;\n border-bottom: 1px solid var(--border);\n }\n .sidebar-title {\n font-family: var(--mono);\n font-size: 11px;\n font-weight: 600;\n color: var(--text);\n letter-spacing: 0.02em;\n text-transform: lowercase;\n display: flex;\n align-items: center;\n gap: 6px;\n }\n .sidebar-meta {\n font-family: var(--mono);\n font-size: 10.5px;\n color: var(--text-muted);\n margin-top: 4px;\n letter-spacing: 0.02em;\n }\n .sidebar-search {\n margin: 10px 12px 4px;\n position: relative;\n }\n .sidebar-search input {\n width: 100%;\n padding: 7px 10px 7px 28px;\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n color: var(--text);\n font-size: 12px;\n transition: border-color var(--t), background var(--t);\n }\n .sidebar-search input:focus {\n outline: none;\n border-color: var(--border-strong);\n }\n .sidebar-search input::placeholder {\n color: var(--text-muted);\n }\n .sidebar-search-icon {\n position: absolute;\n left: 9px;\n top: 50%;\n transform: translateY(-50%);\n color: var(--text-muted);\n width: 13px;\n height: 13px;\n pointer-events: none;\n }\n .sidebar-scroll {\n flex: 1;\n overflow-y: auto;\n padding: 8px 0 16px;\n }\n .sidebar-group {\n padding: 4px 0;\n }\n .sidebar-group-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 16px 4px;\n font-family: var(--mono);\n font-size: 10px;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n cursor: pointer;\n user-select: none;\n transition: color var(--t);\n }\n .sidebar-group-header:hover {\n color: var(--text-dim);\n }\n .sidebar-group-header .chev {\n transition: transform var(--t);\n }\n .sidebar-group.collapsed .chev {\n transform: rotate(-90deg);\n }\n .sidebar-group.collapsed .sidebar-group-body {\n display: none;\n }\n .sidebar-group-body {\n display: flex;\n flex-direction: column;\n }\n .sidebar-item {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 5px 16px 5px 22px;\n font-size: 12.5px;\n font-family: var(--mono);\n color: var(--text-dim);\n cursor: pointer;\n transition: background var(--t), color var(--t);\n user-select: none;\n min-width: 0;\n }\n .sidebar-item-name {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n min-width: 0;\n }\n .sidebar-item-count {\n margin-left: 8px;\n font-size: 10.5px;\n color: var(--text-muted);\n flex-shrink: 0;\n }\n .sidebar-item:hover {\n background: var(--bg-hover);\n color: var(--text);\n }\n .sidebar-item.active {\n background: var(--bg-active);\n color: var(--text);\n border-left: 2px solid var(--accent);\n padding-left: 20px;\n }\n .sidebar-item.active .sidebar-item-count {\n color: var(--text-dim);\n }\n .sidebar-empty {\n padding: 6px 22px;\n font-size: 11.5px;\n color: var(--text-muted);\n font-style: italic;\n }\n\n .saved-group {\n margin-top: 6px;\n }\n .saved-table-header {\n padding: 6px 16px 3px 16px;\n font-family: var(--mono);\n font-size: 10px;\n color: var(--text-muted);\n text-transform: lowercase;\n letter-spacing: 0.02em;\n }\n .saved-query {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 4px 16px 4px 22px;\n font-size: 12px;\n color: var(--text-dim);\n cursor: pointer;\n transition: background var(--t), color var(--t);\n }\n .saved-query:hover {\n background: var(--bg-hover);\n color: var(--text);\n }\n .saved-query .saved-kind {\n font-family: var(--mono);\n font-size: 9px;\n padding: 1px 5px;\n border-radius: 3px;\n border: 1px solid var(--border);\n color: var(--text-muted);\n text-transform: uppercase;\n }\n .saved-query-name {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n flex: 1;\n }\n .saved-query-del {\n opacity: 0;\n transition: opacity var(--t);\n color: var(--text-muted);\n padding: 0 2px;\n }\n .saved-query:hover .saved-query-del {\n opacity: 1;\n }\n .saved-query-del:hover {\n color: var(--red);\n }\n\n /* Sidebar resizer */\n .sidebar-resizer {\n position: absolute;\n top: 0;\n right: -3px;\n width: 6px;\n height: 100%;\n cursor: col-resize;\n z-index: 10;\n user-select: none;\n }\n .sidebar-resizer:hover::after,\n .sidebar-resizer.dragging::after {\n content: \"\";\n position: absolute;\n right: 2px;\n top: 0;\n width: 2px;\n height: 100%;\n background: var(--accent);\n }\n\n /* =====================================================================\n Main area\n ===================================================================== */\n .main {\n display: flex;\n flex-direction: column;\n min-width: 0;\n min-height: 0;\n overflow: hidden;\n }\n .tabs {\n display: flex;\n align-items: center;\n gap: 2px;\n padding: 0 16px;\n border-bottom: 1px solid var(--border);\n background: var(--bg-elev);\n height: 38px;\n flex-shrink: 0;\n }\n .tab {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n padding: 0 12px;\n height: 38px;\n font-size: 12.5px;\n color: var(--text-dim);\n border-bottom: 2px solid transparent;\n margin-bottom: -1px;\n transition: color var(--t), border-color var(--t);\n user-select: none;\n }\n .tab:hover {\n color: var(--text);\n }\n .tab.active {\n color: var(--text);\n border-bottom-color: var(--accent);\n }\n .tab-icon {\n width: 13px;\n height: 13px;\n opacity: 0.7;\n }\n .tab.active .tab-icon {\n opacity: 1;\n }\n\n .pane {\n flex: 1;\n min-height: 0;\n display: none;\n flex-direction: column;\n overflow: hidden;\n }\n .pane.active {\n display: flex;\n }\n .pane-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 16px;\n padding: 14px 20px;\n border-bottom: 1px solid var(--border);\n flex-shrink: 0;\n }\n .pane-title {\n display: flex;\n align-items: baseline;\n gap: 10px;\n min-width: 0;\n }\n .pane-title h1 {\n margin: 0;\n font-size: 18px;\n font-weight: 600;\n font-family: var(--mono);\n letter-spacing: -0.01em;\n color: var(--text);\n }\n .pane-title .muted {\n font-size: 12px;\n color: var(--text-muted);\n font-family: var(--mono);\n }\n .toolbar {\n display: flex;\n align-items: center;\n gap: 8px;\n }\n .search-input {\n position: relative;\n }\n .search-input input {\n padding: 6px 10px 6px 28px;\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n color: var(--text);\n font-size: 12px;\n width: 220px;\n transition: border-color var(--t), width var(--t);\n font-family: var(--mono);\n }\n .search-input input:focus {\n outline: none;\n border-color: var(--border-strong);\n width: 280px;\n }\n .search-input-icon {\n position: absolute;\n left: 9px;\n top: 50%;\n transform: translateY(-50%);\n color: var(--text-muted);\n width: 13px;\n height: 13px;\n pointer-events: none;\n }\n\n /* Buttons */\n .btn {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 6px 12px;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: var(--bg);\n color: var(--text);\n font-size: 12px;\n transition: all var(--t);\n white-space: nowrap;\n }\n .btn:hover {\n background: var(--bg-hover);\n border-color: var(--border-strong);\n }\n .btn:disabled {\n opacity: 0.5;\n cursor: not-allowed;\n }\n .btn-primary {\n background: var(--accent);\n border-color: var(--accent);\n color: #0a0a0b;\n font-weight: 500;\n }\n .btn-primary:hover:not(:disabled) {\n background: var(--accent-hover);\n border-color: var(--accent-hover);\n }\n .btn-ghost {\n border-color: transparent;\n background: transparent;\n color: var(--text-dim);\n }\n .btn-ghost:hover {\n background: var(--bg-hover);\n color: var(--text);\n }\n .btn-sm {\n padding: 4px 8px;\n font-size: 11px;\n }\n\n /* =====================================================================\n Data table\n ===================================================================== */\n .data-wrap {\n flex: 1;\n min-height: 0;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n .data-scroll {\n flex: 1;\n overflow: auto;\n min-height: 0;\n position: relative;\n }\n table.data {\n border-collapse: separate;\n border-spacing: 0;\n width: 100%;\n font-family: var(--mono);\n font-size: 12px;\n }\n table.data thead th {\n position: sticky;\n top: 0;\n background: var(--bg-elev);\n border-bottom: 1px solid var(--border);\n padding: 8px 14px;\n text-align: left;\n font-weight: 500;\n color: var(--text-dim);\n font-size: 11px;\n /* Column names render verbatim (created_at, not CREATED_AT) — casing is\n schema information, never cosmetic. */\n letter-spacing: 0.03em;\n white-space: nowrap;\n z-index: 2;\n user-select: none;\n }\n table.data thead th.sortable {\n cursor: pointer;\n transition: color var(--t), background var(--t);\n }\n table.data thead th.sortable:hover {\n color: var(--text);\n background: var(--bg-hover);\n }\n table.data thead th .sort-ind {\n color: var(--accent);\n margin-left: 4px;\n font-size: 10px;\n }\n table.data tbody td {\n padding: 7px 14px;\n border-bottom: 1px solid var(--border);\n color: var(--text);\n max-width: 360px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n vertical-align: middle;\n }\n table.data tbody tr {\n transition: background var(--t);\n }\n table.data tbody tr:hover {\n background: var(--bg-hover);\n }\n .cell-null {\n color: var(--text-muted);\n font-style: italic;\n }\n .cell-json {\n color: var(--purple);\n }\n .cell-json .expand-btn {\n margin-left: 6px;\n font-size: 10px;\n color: var(--text-muted);\n padding: 1px 4px;\n border: 1px solid var(--border);\n border-radius: 3px;\n transition: all var(--t);\n }\n .cell-json .expand-btn:hover {\n color: var(--accent);\n border-color: var(--accent);\n }\n .cell-number {\n color: var(--green);\n }\n .cell-bool {\n color: var(--orange);\n }\n .data-footer {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 10px 20px;\n border-top: 1px solid var(--border);\n background: var(--bg-elev);\n font-size: 11.5px;\n color: var(--text-dim);\n flex-shrink: 0;\n font-family: var(--mono);\n }\n .pagination {\n display: flex;\n align-items: center;\n gap: 8px;\n }\n\n /* States */\n .empty,\n .error-box,\n .loading {\n padding: 40px 24px;\n text-align: center;\n color: var(--text-dim);\n font-size: 13px;\n }\n .empty-icon {\n margin: 0 auto 10px;\n width: 28px;\n height: 28px;\n color: var(--text-muted);\n }\n .error-box {\n margin: 16px;\n padding: 14px 18px;\n border: 1px solid rgba(248, 113, 113, 0.35);\n background: rgba(248, 113, 113, 0.06);\n border-radius: var(--radius);\n color: var(--red);\n text-align: left;\n font-family: var(--mono);\n font-size: 12px;\n white-space: pre-wrap;\n }\n .error-box-title {\n font-weight: 600;\n margin-bottom: 4px;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n font-size: 10.5px;\n }\n\n /* Skeleton */\n @keyframes shimmer {\n 0% {\n background-position: -200% 0;\n }\n 100% {\n background-position: 200% 0;\n }\n }\n .skeleton-row {\n display: flex;\n gap: 16px;\n padding: 10px 14px;\n border-bottom: 1px solid var(--border);\n }\n .skeleton-cell {\n height: 10px;\n border-radius: 3px;\n background: linear-gradient(\n 90deg,\n #1a1a1d 0%,\n #22222a 50%,\n #1a1a1d 100%\n );\n background-size: 200% 100%;\n animation: shimmer 1.4s linear infinite;\n flex: 1;\n }\n\n /* =====================================================================\n Schema pane\n ===================================================================== */\n .schema-scroll {\n flex: 1;\n overflow: auto;\n padding: 24px 32px 40px;\n }\n .schema-section {\n margin-bottom: 32px;\n }\n .schema-section h2 {\n font-size: 11px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-dim);\n margin: 0 0 12px;\n font-family: var(--mono);\n }\n .col-table {\n width: 100%;\n border-collapse: separate;\n border-spacing: 0;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n overflow: hidden;\n background: var(--bg-elev);\n }\n .col-table th,\n .col-table td {\n padding: 9px 14px;\n text-align: left;\n font-family: var(--mono);\n font-size: 12px;\n border-bottom: 1px solid var(--border);\n }\n .col-table th {\n font-size: 10.5px;\n font-weight: 500;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n color: var(--text-muted);\n background: var(--bg);\n }\n .col-table tr:last-child td {\n border-bottom: none;\n }\n .col-name {\n color: var(--text);\n font-weight: 500;\n }\n .col-name.pk {\n color: var(--orange);\n }\n .col-ts {\n color: var(--green);\n }\n .col-pg {\n color: var(--text-dim);\n }\n .col-rel-name {\n color: var(--accent);\n }\n .badge {\n display: inline-block;\n padding: 1px 6px;\n border-radius: 3px;\n font-size: 9.5px;\n font-weight: 500;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n margin-right: 4px;\n font-family: var(--mono);\n border: 1px solid transparent;\n }\n .badge.pk {\n background: rgba(251, 146, 60, 0.12);\n color: var(--orange);\n border-color: rgba(251, 146, 60, 0.25);\n }\n .badge.nn {\n background: rgba(248, 113, 113, 0.1);\n color: var(--red);\n border-color: rgba(248, 113, 113, 0.2);\n }\n .badge.def {\n background: rgba(167, 139, 250, 0.1);\n color: var(--purple);\n border-color: rgba(167, 139, 250, 0.2);\n }\n .badge.nullable {\n background: var(--bg);\n color: var(--text-muted);\n border-color: var(--border);\n }\n .badge.rel-type {\n background: var(--accent-dim);\n color: var(--accent);\n border-color: rgba(96, 165, 250, 0.25);\n }\n .schema-details {\n margin-top: 18px;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: var(--bg-elev);\n overflow: hidden;\n }\n .schema-details summary {\n padding: 10px 14px;\n font-family: var(--mono);\n font-size: 11px;\n color: var(--text-dim);\n cursor: pointer;\n user-select: none;\n list-style: none;\n display: flex;\n align-items: center;\n gap: 8px;\n }\n .schema-details summary::-webkit-details-marker {\n display: none;\n }\n .schema-details summary::before {\n content: \"›\";\n color: var(--text-muted);\n transition: transform var(--t);\n display: inline-block;\n }\n .schema-details[open] summary::before {\n transform: rotate(90deg);\n }\n .schema-details pre {\n margin: 0;\n padding: 14px 16px;\n font-family: var(--mono);\n font-size: 11.5px;\n line-height: 1.6;\n color: var(--text-dim);\n overflow-x: auto;\n border-top: 1px solid var(--border);\n background: var(--bg);\n }\n .sql-kw {\n color: var(--accent);\n font-weight: 500;\n }\n .sql-type {\n color: var(--green);\n }\n .sql-ident {\n color: var(--text);\n }\n .sql-str {\n color: var(--yellow);\n }\n\n /* =====================================================================\n Builder pane\n ===================================================================== */\n .builder-wrap {\n flex: 1;\n min-height: 0;\n display: grid;\n grid-template-columns: 1fr 1fr;\n min-width: 0;\n }\n .builder-left {\n border-right: 1px solid var(--border);\n overflow-y: auto;\n padding: 20px 24px;\n }\n .builder-right {\n display: flex;\n flex-direction: column;\n min-height: 0;\n background: var(--bg);\n min-width: 0;\n }\n .builder-preview {\n padding: 18px 22px;\n font-family: var(--mono);\n font-size: 12.5px;\n line-height: 1.65;\n color: var(--text);\n white-space: pre;\n overflow: auto;\n flex: 1;\n min-height: 0;\n border-bottom: 1px solid var(--border);\n }\n .tok-kw {\n color: var(--accent);\n }\n .tok-fn {\n color: var(--yellow);\n }\n .tok-str {\n color: var(--green);\n }\n .tok-num {\n color: var(--orange);\n }\n .tok-key {\n color: var(--purple);\n }\n .tok-punc {\n color: var(--text-dim);\n }\n .tok-comment {\n color: var(--text-muted);\n font-style: italic;\n }\n .builder-results {\n flex: 1;\n min-height: 200px;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n .builder-results-header {\n padding: 10px 22px;\n font-family: var(--mono);\n font-size: 11px;\n color: var(--text-dim);\n border-bottom: 1px solid var(--border);\n background: var(--bg-elev);\n text-transform: uppercase;\n letter-spacing: 0.04em;\n flex-shrink: 0;\n }\n\n .builder-section {\n margin-bottom: 18px;\n }\n .builder-section-title {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding-bottom: 8px;\n margin-bottom: 10px;\n border-bottom: 1px dashed var(--border);\n }\n .builder-section-title h3 {\n margin: 0;\n font-size: 11px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.06em;\n color: var(--text-dim);\n font-family: var(--mono);\n }\n .builder-field {\n margin-bottom: 10px;\n }\n .builder-field label {\n display: block;\n font-size: 11px;\n color: var(--text-muted);\n margin-bottom: 4px;\n font-family: var(--mono);\n text-transform: uppercase;\n letter-spacing: 0.04em;\n }\n .builder-input,\n .builder-select {\n width: 100%;\n padding: 6px 10px;\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n color: var(--text);\n font-size: 12px;\n font-family: var(--mono);\n transition: border-color var(--t);\n }\n .builder-input:focus,\n .builder-select:focus {\n outline: none;\n border-color: var(--border-strong);\n }\n .builder-input.invalid,\n .builder-select.invalid {\n border-color: rgba(248, 113, 113, 0.5);\n }\n .field-hint {\n font-size: 10.5px;\n color: var(--red);\n margin-top: 3px;\n font-family: var(--mono);\n }\n .where-clauses {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n .where-clause {\n display: grid;\n grid-template-columns: 1fr 1fr 1.2fr auto;\n gap: 6px;\n align-items: center;\n }\n .where-clause .btn-ghost {\n padding: 4px 6px;\n }\n .clause-row {\n display: flex;\n gap: 6px;\n align-items: center;\n }\n .combinator-pick {\n display: flex;\n gap: 0;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n overflow: hidden;\n background: var(--bg);\n }\n .combinator-pick button {\n padding: 4px 10px;\n font-family: var(--mono);\n font-size: 10.5px;\n color: var(--text-dim);\n border-right: 1px solid var(--border);\n transition: background var(--t), color var(--t);\n }\n .combinator-pick button:last-child {\n border-right: none;\n }\n .combinator-pick button.active {\n background: var(--accent-dim);\n color: var(--accent);\n }\n .combinator-pick button:hover:not(.active) {\n background: var(--bg-hover);\n }\n .with-rels {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n .with-rel {\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: var(--bg);\n }\n .with-rel-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 7px 10px;\n cursor: pointer;\n user-select: none;\n gap: 8px;\n }\n .with-rel-header:hover {\n background: var(--bg-hover);\n }\n .with-rel-name {\n font-family: var(--mono);\n font-size: 11.5px;\n color: var(--accent);\n }\n .with-rel-meta {\n font-family: var(--mono);\n font-size: 10px;\n color: var(--text-muted);\n }\n .with-rel-body {\n padding: 10px;\n border-top: 1px solid var(--border);\n display: none;\n }\n .with-rel.expanded .with-rel-body {\n display: block;\n }\n .with-rel .chev {\n transition: transform var(--t);\n color: var(--text-muted);\n }\n .with-rel.expanded .chev {\n transform: rotate(90deg);\n }\n .chip-row {\n display: flex;\n gap: 6px;\n flex-wrap: wrap;\n }\n .chip {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n padding: 3px 8px;\n border: 1px solid var(--border);\n border-radius: 20px;\n background: var(--bg);\n font-family: var(--mono);\n font-size: 10.5px;\n color: var(--text-dim);\n cursor: pointer;\n transition: all var(--t);\n }\n .chip:hover {\n border-color: var(--border-strong);\n color: var(--text);\n }\n .chip.active {\n background: var(--accent-dim);\n border-color: rgba(96, 165, 250, 0.35);\n color: var(--accent);\n }\n\n /* =====================================================================\n Modal / dialog\n ===================================================================== */\n .modal-backdrop {\n position: fixed;\n inset: 0;\n background: rgba(0, 0, 0, 0.65);\n display: none;\n align-items: center;\n justify-content: center;\n z-index: 100;\n backdrop-filter: blur(4px);\n }\n .modal-backdrop.open {\n display: flex;\n animation: fade-in 150ms var(--ease);\n }\n @keyframes fade-in {\n from {\n opacity: 0;\n }\n to {\n opacity: 1;\n }\n }\n @keyframes slide-up {\n from {\n opacity: 0;\n transform: translateY(8px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n }\n .modal {\n background: var(--bg-elev);\n border: 1px solid var(--border);\n border-radius: var(--radius-lg);\n box-shadow: var(--shadow-lg);\n min-width: 400px;\n max-width: 90vw;\n max-height: 85vh;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n animation: slide-up 180ms var(--ease);\n }\n .modal-header {\n padding: 14px 18px;\n border-bottom: 1px solid var(--border);\n display: flex;\n align-items: center;\n justify-content: space-between;\n }\n .modal-title {\n font-size: 13px;\n font-weight: 600;\n color: var(--text);\n font-family: var(--mono);\n }\n .modal-body {\n padding: 16px 18px;\n overflow-y: auto;\n flex: 1;\n min-height: 0;\n }\n .modal-footer {\n padding: 12px 18px;\n border-top: 1px solid var(--border);\n display: flex;\n justify-content: flex-end;\n gap: 8px;\n }\n .modal-field {\n margin-bottom: 12px;\n }\n .modal-field label {\n display: block;\n font-size: 11px;\n color: var(--text-dim);\n margin-bottom: 5px;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n font-family: var(--mono);\n }\n .modal-field input,\n .modal-field select {\n width: 100%;\n padding: 7px 10px;\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n color: var(--text);\n font-family: var(--mono);\n font-size: 12px;\n }\n .modal-field input:focus,\n .modal-field select:focus {\n outline: none;\n border-color: var(--border-strong);\n }\n .json-view {\n font-family: var(--mono);\n font-size: 12px;\n color: var(--text);\n white-space: pre-wrap;\n word-break: break-word;\n }\n\n /* =====================================================================\n Command palette\n ===================================================================== */\n .palette {\n width: 560px;\n max-width: 92vw;\n }\n .palette-input {\n width: 100%;\n padding: 14px 18px;\n background: transparent;\n border: none;\n border-bottom: 1px solid var(--border);\n color: var(--text);\n font-size: 14px;\n font-family: var(--sans);\n }\n .palette-input:focus {\n outline: none;\n }\n .palette-input::placeholder {\n color: var(--text-muted);\n }\n .palette-list {\n max-height: 50vh;\n overflow-y: auto;\n padding: 6px 0;\n }\n .palette-section {\n padding: 8px 16px 4px;\n font-size: 10px;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n font-family: var(--mono);\n }\n .palette-item {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 16px;\n cursor: pointer;\n transition: background var(--t);\n font-size: 13px;\n }\n .palette-item:hover,\n .palette-item.selected {\n background: var(--bg-hover);\n }\n .palette-item .pi-name {\n display: flex;\n align-items: center;\n gap: 10px;\n }\n .palette-item .pi-icon {\n color: var(--text-dim);\n width: 14px;\n height: 14px;\n }\n .palette-item .pi-kbd {\n display: flex;\n gap: 3px;\n }\n\n /* =====================================================================\n Responsive\n ===================================================================== */\n @media (max-width: 1024px) {\n .body {\n grid-template-columns: 1fr;\n }\n .sidebar {\n position: fixed;\n top: var(--header-h);\n left: 0;\n bottom: 0;\n width: 280px;\n transform: translateX(-100%);\n transition: transform 200ms var(--ease);\n z-index: 50;\n }\n .sidebar.open {\n transform: translateX(0);\n }\n .hamburger {\n display: inline-flex;\n }\n .sidebar-resizer {\n display: none;\n }\n .search-input input {\n width: 160px;\n }\n .search-input input:focus {\n width: 200px;\n }\n }\n\n /* =====================================================================\n Data-tab filter bar\n ===================================================================== */\n .filter-bar {\n display: flex;\n flex-direction: column;\n gap: 6px;\n padding: 8px 20px;\n border-bottom: 1px solid var(--border);\n background: var(--bg-elev);\n flex-shrink: 0;\n }\n .filter-row {\n display: grid;\n grid-template-columns: minmax(120px, 1fr) minmax(100px, 0.8fr) minmax(140px, 1.4fr) auto;\n gap: 6px;\n align-items: center;\n }\n .filter-row .builder-select,\n .filter-row .builder-input {\n padding: 5px 8px;\n font-size: 11.5px;\n }\n .filter-row .icon-btn {\n width: 24px;\n height: 24px;\n }\n\n /* =====================================================================\n Row selection + bulk actions\n ===================================================================== */\n table.data th.sel-col,\n table.data td.sel-col {\n width: 34px;\n max-width: 34px;\n padding: 0 0 0 14px;\n text-align: left;\n }\n table.data input[type=\"checkbox\"] {\n accent-color: var(--accent);\n cursor: pointer;\n width: 13px;\n height: 13px;\n vertical-align: middle;\n }\n table.data tbody tr.selected {\n background: var(--accent-dim);\n }\n table.data thead th.no-sort {\n cursor: default;\n }\n table.data thead th.no-sort:hover {\n color: var(--text-dim);\n background: var(--bg-elev);\n }\n .selection-bar {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 6px 20px;\n border-bottom: 1px solid var(--border);\n background: var(--accent-dim);\n font-size: 12px;\n color: var(--text);\n flex-shrink: 0;\n font-family: var(--mono);\n }\n .selection-bar .btn {\n padding: 3px 9px;\n font-size: 11px;\n }\n .selection-count {\n color: var(--accent);\n font-weight: 600;\n }\n\n /* =====================================================================\n Builder SQL panel + validation\n ===================================================================== */\n .builder-validation {\n padding: 8px 22px;\n border-bottom: 1px solid rgba(248, 113, 113, 0.35);\n background: rgba(248, 113, 113, 0.06);\n color: var(--red);\n font-family: var(--mono);\n font-size: 11.5px;\n flex-shrink: 0;\n display: flex;\n flex-direction: column;\n gap: 2px;\n }\n .builder-results-header {\n display: flex;\n align-items: center;\n gap: 10px;\n }\n .builder-results-header .spacer {\n flex: 1;\n }\n .builder-results-header .btn {\n padding: 2px 8px;\n font-size: 10.5px;\n text-transform: none;\n letter-spacing: normal;\n }\n .builder-sql {\n margin: 0;\n padding: 12px 22px;\n border-bottom: 1px solid var(--border);\n background: var(--bg);\n font-family: var(--mono);\n font-size: 11.5px;\n line-height: 1.6;\n color: var(--green);\n white-space: pre-wrap;\n word-break: break-word;\n max-height: 180px;\n overflow: auto;\n flex-shrink: 0;\n }\n\n /* =====================================================================\n Paste-rows modal (write mode)\n ===================================================================== */\n .paste-area {\n width: 100%;\n min-height: 180px;\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n color: var(--text);\n font-family: var(--mono);\n font-size: 12px;\n padding: 10px;\n resize: vertical;\n }\n .paste-area:focus {\n outline: none;\n border-color: var(--border-strong);\n }\n .paste-hint {\n font-size: 11px;\n color: var(--text-dim);\n margin-bottom: 8px;\n line-height: 1.6;\n }\n .paste-hint code {\n font-family: var(--mono);\n color: var(--accent);\n }\n .paste-preview {\n margin-top: 10px;\n font-family: var(--mono);\n font-size: 11.5px;\n color: var(--text-dim);\n white-space: pre-wrap;\n }\n .paste-preview.err {\n color: var(--red);\n }\n\n /* Page-size picker */\n .page-size {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n margin-right: 14px;\n }\n .page-size select {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n color: var(--text);\n font-family: var(--mono);\n font-size: 11px;\n padding: 3px 6px;\n }\n\n .toast-wrap {\n position: fixed;\n bottom: 24px;\n right: 24px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n z-index: 200;\n }\n .toast {\n padding: 10px 14px;\n background: var(--bg-elev);\n border: 1px solid var(--border-strong);\n border-radius: var(--radius);\n font-size: 12px;\n color: var(--text);\n box-shadow: var(--shadow-lg);\n animation: slide-up 200ms var(--ease);\n max-width: 320px;\n }\n .toast.ok {\n border-left: 3px solid var(--green);\n }\n .toast.err {\n border-left: 3px solid var(--red);\n }\n </style>\n</head>\n<body>\n <div class=\"app\" id=\"app\">\n <!-- Write-mode banner (shown only when the server runs with --write) -->\n <div class=\"write-banner\" id=\"writeBanner\" role=\"alert\" hidden>\n <span class=\"write-banner-dot\" aria-hidden=\"true\"></span>\n <strong>WRITE MODE</strong>\n <span>changes are committed directly to your database</span>\n </div>\n <!-- PII banner (shown only when the server runs with --show-pii) -->\n <div class=\"pii-banner\" id=\"piiBanner\" role=\"alert\" hidden>\n <strong>PII SHOWN</strong>\n <span>PII-tagged column values are displayed unredacted (--show-pii)</span>\n </div>\n <!-- Demo banner + live mode switcher (shown only in --demo mode) -->\n <div class=\"demo-banner\" id=\"demoBanner\" role=\"note\" hidden>\n <strong>DEMO</strong>\n <span class=\"demo-msg\">Seeded sample data. Nothing you do here is saved anywhere; every launch starts fresh.</span>\n <div class=\"demo-switcher\" role=\"group\" aria-label=\"Demo mode switcher\">\n <button class=\"demo-toggle\" id=\"demoTogglePii\" type=\"button\" aria-pressed=\"false\">\n <span class=\"demo-toggle-dot\" aria-hidden=\"true\"></span>\n <span id=\"demoTogglePiiLabel\">PII: hidden</span>\n </button>\n <button class=\"demo-toggle\" id=\"demoToggleWrite\" type=\"button\" aria-pressed=\"false\">\n <span class=\"demo-toggle-dot\" aria-hidden=\"true\"></span>\n <span id=\"demoToggleWriteLabel\">Writes: off</span>\n </button>\n </div>\n </div>\n <!-- Header -->\n <header class=\"header\">\n <div class=\"brand\">\n <button class=\"icon-btn hamburger\" id=\"hamburger\" aria-label=\"Toggle sidebar\">\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <path d=\"M3 6h18M3 12h18M3 18h18\" />\n </svg>\n </button>\n <span class=\"brand-mark\" aria-hidden=\"true\"></span>\n <span>turbine</span>\n <span class=\"brand-dot\">·</span>\n <span class=\"brand-sub\">studio</span>\n </div>\n <div class=\"header-right\">\n <button class=\"header-cmd\" id=\"openPalette\" aria-label=\"Open command palette\">\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <circle cx=\"11\" cy=\"11\" r=\"7\" />\n <path d=\"m21 21-4.3-4.3\" />\n </svg>\n <span>Jump to…</span>\n <span class=\"kbd\">⌘K</span>\n </button>\n <span class=\"header-meta\" id=\"headerMeta\">—</span>\n <div class=\"conn\" id=\"conn\">\n <span class=\"conn-dot\" id=\"connDot\"></span>\n <span id=\"connLabel\">connecting</span>\n </div>\n </div>\n </header>\n\n <div class=\"body\">\n <!-- Sidebar -->\n <aside class=\"sidebar\" id=\"sidebar\" aria-label=\"Schema sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"sidebar-title\">\n <span>schema</span>\n </div>\n <div class=\"sidebar-meta\" id=\"sidebarMeta\">—</div>\n </div>\n <div class=\"sidebar-search\">\n <svg class=\"sidebar-search-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <circle cx=\"11\" cy=\"11\" r=\"7\" />\n <path d=\"m21 21-4.3-4.3\" />\n </svg>\n <input id=\"sidebarSearch\" type=\"text\" placeholder=\"Filter tables…\" aria-label=\"Filter tables\" />\n </div>\n <div class=\"sidebar-scroll\">\n <div class=\"sidebar-group\" id=\"groupTables\">\n <div class=\"sidebar-group-header\" data-group=\"groupTables\">\n <span>Tables</span>\n <span class=\"chev\">▾</span>\n </div>\n <div class=\"sidebar-group-body\" id=\"tablesList\"></div>\n </div>\n <div class=\"sidebar-group collapsed\" id=\"groupEnums\">\n <div class=\"sidebar-group-header\" data-group=\"groupEnums\">\n <span>Enums</span>\n <span class=\"chev\">▾</span>\n </div>\n <div class=\"sidebar-group-body\" id=\"enumsList\"></div>\n </div>\n <div class=\"sidebar-group\" id=\"groupSaved\">\n <div class=\"sidebar-group-header\" data-group=\"groupSaved\">\n <span>Saved Queries</span>\n <span class=\"chev\">▾</span>\n </div>\n <div class=\"sidebar-group-body\" id=\"savedList\"></div>\n </div>\n </div>\n <div class=\"sidebar-resizer\" id=\"sidebarResizer\"></div>\n </aside>\n\n <!-- Main -->\n <main class=\"main\">\n <nav class=\"tabs\" role=\"tablist\">\n <button class=\"tab active\" data-tab=\"builder\" role=\"tab\" aria-selected=\"true\">\n <svg class=\"tab-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <path d=\"M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z\" />\n </svg>\n Query\n </button>\n <button class=\"tab\" data-tab=\"data\" role=\"tab\">\n <svg class=\"tab-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" />\n <path d=\"M3 9h18M3 15h18M9 3v18M15 3v18\" />\n </svg>\n Data\n </button>\n <button class=\"tab\" data-tab=\"schema\" role=\"tab\">\n <svg class=\"tab-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <ellipse cx=\"12\" cy=\"5\" rx=\"9\" ry=\"3\" />\n <path d=\"M3 5v14a9 3 0 0 0 18 0V5\" />\n <path d=\"M3 12a9 3 0 0 0 18 0\" />\n </svg>\n Schema\n </button>\n </nav>\n\n <!-- Data pane -->\n <section class=\"pane\" id=\"pane-data\" role=\"tabpanel\">\n <div class=\"pane-header\">\n <div class=\"pane-title\">\n <h1 id=\"dataTitle\">—</h1>\n <span class=\"muted\" id=\"dataTotal\"></span>\n </div>\n <div class=\"toolbar\">\n <div class=\"search-input\">\n <svg class=\"search-input-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <circle cx=\"11\" cy=\"11\" r=\"7\" />\n <path d=\"m21 21-4.3-4.3\" />\n </svg>\n <input id=\"dataSearch\" type=\"text\" placeholder=\"Search all text columns…\" aria-label=\"Full text search\" />\n </div>\n <button class=\"btn btn-sm\" id=\"dataFilterAdd\" aria-label=\"Add column filter\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <path d=\"M4 5h16M7 12h10M10 19h4\" />\n </svg>\n Filter\n </button>\n <button class=\"btn btn-ghost btn-sm\" id=\"dataExport\" aria-label=\"Export rows\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <path d=\"M12 3v12M6 9l6 6 6-6\" />\n <path d=\"M4 21h16\" />\n </svg>\n Export\n </button>\n <button class=\"btn btn-ghost btn-sm\" id=\"dataRefresh\" aria-label=\"Refresh\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <path d=\"M21 12a9 9 0 1 1-3-6.7L21 8\" />\n <path d=\"M21 3v5h-5\" />\n </svg>\n Refresh\n </button>\n <button class=\"btn btn-sm\" id=\"dataPaste\" aria-label=\"Paste rows to insert\" hidden>\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <rect x=\"7\" y=\"4\" width=\"10\" height=\"4\" rx=\"1\" />\n <path d=\"M9 4H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-3\" />\n </svg>\n Paste rows\n </button>\n <button class=\"btn btn-primary btn-sm\" id=\"dataInsert\" aria-label=\"Insert row\" hidden>\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <path d=\"M12 5v14M5 12h14\" />\n </svg>\n Insert row\n </button>\n </div>\n </div>\n <div class=\"filter-bar\" id=\"dataFilterBar\" hidden></div>\n <div class=\"selection-bar\" id=\"dataSelectionBar\" hidden>\n <span class=\"selection-count\" id=\"selectionCount\">0 selected</span>\n <button class=\"btn\" id=\"selCopyJson\">Copy JSON</button>\n <button class=\"btn\" id=\"selCopyCsv\">Copy CSV</button>\n <button class=\"btn\" id=\"selDelete\" hidden>Delete selected</button>\n <button class=\"btn btn-ghost\" id=\"selClear\">Clear</button>\n </div>\n <div class=\"data-wrap\">\n <div class=\"data-scroll\" id=\"dataScroll\"></div>\n <div class=\"data-footer\">\n <span id=\"dataRange\">—</span>\n <div class=\"pagination\">\n <span class=\"page-size\">\n <label for=\"pageSize\">Rows</label>\n <select id=\"pageSize\" aria-label=\"Rows per page\">\n <option value=\"25\">25</option>\n <option value=\"50\" selected>50</option>\n <option value=\"100\">100</option>\n <option value=\"250\">250</option>\n <option value=\"500\">500</option>\n </select>\n </span>\n <button class=\"btn btn-sm\" id=\"pagePrev\">← Prev</button>\n <span id=\"pageIndicator\">—</span>\n <button class=\"btn btn-sm\" id=\"pageNext\">Next →</button>\n </div>\n </div>\n </div>\n </section>\n\n <!-- Schema pane -->\n <section class=\"pane\" id=\"pane-schema\" role=\"tabpanel\">\n <div class=\"pane-header\">\n <div class=\"pane-title\">\n <h1 id=\"schemaTitle\">—</h1>\n <span class=\"muted\" id=\"schemaSub\"></span>\n </div>\n </div>\n <div class=\"schema-scroll\" id=\"schemaScroll\"></div>\n </section>\n\n <!-- SQL pane -->\n <!-- Query (visual findMany builder) pane -->\n <section class=\"pane active\" id=\"pane-builder\" role=\"tabpanel\">\n <div class=\"pane-header\">\n <div class=\"pane-title\">\n <h1>Query</h1>\n <span class=\"muted\" id=\"builderSub\">Visual findMany composer — no SQL required</span>\n </div>\n <div class=\"toolbar\">\n <button class=\"btn btn-primary\" id=\"builderRun\">\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"currentColor\">\n <path d=\"M8 5v14l11-7z\" />\n </svg>\n Run\n </button>\n <button class=\"btn\" id=\"builderSave\">Save</button>\n <button class=\"btn\" id=\"builderCopy\" title=\"Copy generated TypeScript to clipboard\">Copy TS</button>\n <button class=\"btn btn-ghost btn-sm\" id=\"builderReset\">Reset</button>\n </div>\n </div>\n <div class=\"builder-wrap\">\n <div class=\"builder-left\" id=\"builderLeft\"></div>\n <div class=\"builder-right\">\n <div class=\"builder-preview\" id=\"builderPreview\"></div>\n <div class=\"builder-validation\" id=\"builderValidation\" hidden></div>\n <div class=\"builder-results\">\n <div class=\"builder-results-header\">\n <span id=\"builderResultsHeader\">Results</span>\n <span class=\"spacer\"></span>\n <button class=\"btn\" id=\"builderSqlToggle\" hidden>View SQL</button>\n <button class=\"btn\" id=\"builderSqlCopy\" hidden>Copy SQL</button>\n </div>\n <pre class=\"builder-sql\" id=\"builderSql\" hidden></pre>\n <div class=\"data-scroll\" id=\"builderResults\" style=\"flex:1\"></div>\n </div>\n </div>\n </div>\n </section>\n </main>\n </div>\n </div>\n\n <!-- Modals -->\n <div class=\"modal-backdrop\" id=\"savePromptBackdrop\" aria-hidden=\"true\">\n <div class=\"modal\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"savePromptTitle\">\n <div class=\"modal-header\">\n <div class=\"modal-title\" id=\"savePromptTitle\">Save query</div>\n <button class=\"icon-btn\" data-close-modal=\"savePromptBackdrop\" aria-label=\"Close\">×</button>\n </div>\n <div class=\"modal-body\">\n <div class=\"modal-field\">\n <label for=\"savePromptName\">Name</label>\n <input id=\"savePromptName\" type=\"text\" placeholder=\"e.g. recent active users\" />\n </div>\n <div class=\"modal-field\">\n <label for=\"savePromptTable\">Target table</label>\n <!-- Locked: the composed args belong to the builder's current table;\n saving them under another table would produce a broken query. -->\n <select id=\"savePromptTable\" disabled aria-disabled=\"true\"></select>\n </div>\n </div>\n <div class=\"modal-footer\">\n <button class=\"btn btn-ghost\" data-close-modal=\"savePromptBackdrop\">Cancel</button>\n <button class=\"btn btn-primary\" id=\"savePromptConfirm\">Save</button>\n </div>\n </div>\n </div>\n\n <div class=\"modal-backdrop\" id=\"jsonModalBackdrop\" aria-hidden=\"true\">\n <div class=\"modal\" role=\"dialog\" aria-modal=\"true\" style=\"width: 640px\">\n <div class=\"modal-header\">\n <div class=\"modal-title\">JSON value</div>\n <button class=\"icon-btn\" data-close-modal=\"jsonModalBackdrop\" aria-label=\"Close\">×</button>\n </div>\n <div class=\"modal-body\">\n <div class=\"json-view\" id=\"jsonModalBody\"></div>\n </div>\n </div>\n </div>\n\n <div class=\"modal-backdrop\" id=\"paletteBackdrop\" aria-hidden=\"true\">\n <div class=\"modal palette\" role=\"dialog\" aria-modal=\"true\">\n <input\n class=\"palette-input\"\n id=\"paletteInput\"\n type=\"text\"\n placeholder=\"Jump to table, run command…\"\n aria-label=\"Command palette input\"\n />\n <div class=\"palette-list\" id=\"paletteList\"></div>\n </div>\n </div>\n\n <!-- Row editor (write mode only): insert / edit a single row -->\n <div class=\"modal-backdrop\" id=\"rowEditorBackdrop\" aria-hidden=\"true\">\n <div class=\"modal\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"rowEditorTitle\" style=\"width: 560px\">\n <div class=\"modal-header\">\n <div class=\"modal-title\" id=\"rowEditorTitle\">Edit row</div>\n <button class=\"icon-btn\" data-close-modal=\"rowEditorBackdrop\" aria-label=\"Close\">×</button>\n </div>\n <div class=\"modal-body\">\n <div class=\"row-editor-hint\" id=\"rowEditorHint\"></div>\n <div class=\"row-editor-fields\" id=\"rowEditorFields\"></div>\n <div class=\"row-editor-error\" id=\"rowEditorError\" hidden></div>\n </div>\n <div class=\"modal-footer\">\n <button class=\"btn btn-ghost\" data-close-modal=\"rowEditorBackdrop\">Cancel</button>\n <button class=\"btn btn-primary\" id=\"rowEditorConfirm\">Save</button>\n </div>\n </div>\n </div>\n\n <!-- Paste rows (write mode only): bulk insert from TSV/CSV/JSON -->\n <div class=\"modal-backdrop\" id=\"pasteBackdrop\" aria-hidden=\"true\">\n <div class=\"modal\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"pasteTitle\" style=\"width: 640px\">\n <div class=\"modal-header\">\n <div class=\"modal-title\" id=\"pasteTitle\">Paste rows</div>\n <button class=\"icon-btn\" data-close-modal=\"pasteBackdrop\" aria-label=\"Close\">×</button>\n </div>\n <div class=\"modal-body\">\n <div class=\"paste-hint\" id=\"pasteHint\"></div>\n <textarea class=\"paste-area\" id=\"pasteArea\" spellcheck=\"false\" placeholder=\"Paste TSV/CSV (with a header row) or a JSON array of objects…\"></textarea>\n <div class=\"paste-preview\" id=\"pastePreview\" hidden></div>\n </div>\n <div class=\"modal-footer\">\n <button class=\"btn btn-ghost\" data-close-modal=\"pasteBackdrop\">Cancel</button>\n <button class=\"btn btn-primary\" id=\"pasteConfirm\" disabled>Insert 0 rows</button>\n </div>\n </div>\n </div>\n\n <!-- Export current page / selection -->\n <div class=\"modal-backdrop\" id=\"exportBackdrop\" aria-hidden=\"true\">\n <div class=\"modal\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"exportTitle\" style=\"width: 420px\">\n <div class=\"modal-header\">\n <div class=\"modal-title\" id=\"exportTitle\">Export rows</div>\n <button class=\"icon-btn\" data-close-modal=\"exportBackdrop\" aria-label=\"Close\">×</button>\n </div>\n <div class=\"modal-body\">\n <div class=\"paste-hint\" id=\"exportHint\"></div>\n <div style=\"display: flex; flex-direction: column; gap: 8px\">\n <button class=\"btn\" id=\"exportCopyJson\">Copy JSON</button>\n <button class=\"btn\" id=\"exportCopyCsv\">Copy CSV</button>\n <button class=\"btn\" id=\"exportDownloadJson\">Download JSON</button>\n <button class=\"btn\" id=\"exportDownloadCsv\">Download CSV</button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"toast-wrap\" id=\"toastWrap\" aria-live=\"polite\"></div>\n\n <script nonce=\"__CSP_NONCE__\">\n // =====================================================================\n // State\n // =====================================================================\n const State = {\n schema: null, // full /api/schema response\n tables: [], // [{ name, columns, relations, estimatedRows, primaryKey }]\n tablesByName: new Map(),\n enums: {},\n currentTable: null,\n currentTab: 'data',\n connOk: false,\n // write mode / PII / demo (from /api/schema)\n writable: false,\n showPii: false,\n demo: false,\n // data pane\n data: {\n rows: [],\n columns: [],\n total: 0,\n limit: 50,\n offset: 0,\n orderBy: null,\n dir: 'asc',\n search: '',\n // per-column filters composed by the filter bar: { column, op, value }\n filters: [],\n // selected rows keyed by PK signature (Map key -> row object)\n selected: new Map(),\n loading: false,\n error: null,\n },\n // builder (Query) pane\n builder: {\n table: null,\n args: emptyBuilderArgs(),\n result: null,\n error: null,\n running: false,\n validation: {},\n // persisted preference: show the generated SQL under the results header\n showSql: false,\n },\n savedQueries: [],\n sidebarFilter: '',\n // save prompt context (builder queries only)\n savePrompt: {\n kind: 'builder',\n args: null,\n },\n };\n\n function emptyBuilderArgs() {\n return {\n combinator: 'AND',\n where: [],\n with: [], // [{ relation, args: {...} }]\n orderBy: { column: null, dir: 'asc' },\n limit: 10,\n select: [],\n omit: [],\n };\n }\n\n // =====================================================================\n // Storage (localStorage wrapper)\n // =====================================================================\n const Storage = {\n PREFIX: 'turbine_studio_',\n get(key, fallback) {\n try {\n const raw = localStorage.getItem(Storage.PREFIX + key);\n if (raw == null) return fallback;\n return JSON.parse(raw);\n } catch {\n return fallback;\n }\n },\n set(key, val) {\n try {\n localStorage.setItem(Storage.PREFIX + key, JSON.stringify(val));\n } catch {\n /* ignore quota */\n }\n },\n del(key) {\n try {\n localStorage.removeItem(Storage.PREFIX + key);\n } catch {\n /* ignore */\n }\n },\n };\n\n // =====================================================================\n // API client\n // =====================================================================\n const Api = {\n async _fetch(path, opts = {}) {\n const res = await fetch(path, {\n credentials: 'same-origin',\n headers: { 'Content-Type': 'application/json' },\n ...opts,\n });\n let body = null;\n try {\n body = await res.json();\n } catch {\n /* non-JSON response */\n }\n if (!res.ok) {\n let msg = (body && body.error) || res.statusText || 'Request failed';\n if (res.status === 429) {\n const after = (body && body.retryAfter) || res.headers.get('Retry-After');\n msg = 'Rate limited — retry in ' + (after ? after + 's' : 'a moment');\n }\n const err = new Error(msg);\n err.status = res.status;\n throw err;\n }\n return body;\n },\n schema() {\n return Api._fetch('/api/schema');\n },\n rows(table, params) {\n const qs = new URLSearchParams();\n if (params) {\n for (const [k, v] of Object.entries(params)) {\n if (v !== undefined && v !== null && v !== '') qs.set(k, String(v));\n }\n }\n const q = qs.toString();\n return Api._fetch('/api/tables/' + encodeURIComponent(table) + (q ? '?' + q : ''));\n },\n runBuilder(table, args) {\n return Api._fetch('/api/builder', {\n method: 'POST',\n body: JSON.stringify({ table, args }),\n });\n },\n // Write mode only (the routes 404 in read-only mode). `fetch` sets a\n // same-origin Origin header on POST, which the server requires (CSRF).\n rowInsert(table, data) {\n return Api._fetch('/api/row/insert', {\n method: 'POST',\n body: JSON.stringify({ table, data }),\n });\n },\n rowUpdate(table, where, data) {\n return Api._fetch('/api/row/update', {\n method: 'POST',\n body: JSON.stringify({ table, where, data }),\n });\n },\n rowDelete(table, where) {\n return Api._fetch('/api/row/delete', {\n method: 'POST',\n body: JSON.stringify({ table, where }),\n });\n },\n // Bulk forms: `rows` is an array of data objects (insert) or PK-where\n // objects (delete). The server runs them in one all-or-nothing txn.\n rowInsertBulk(table, rows) {\n return Api._fetch('/api/row/insert', {\n method: 'POST',\n body: JSON.stringify({ table, rows }),\n });\n },\n rowDeleteBulk(table, rows) {\n return Api._fetch('/api/row/delete', {\n method: 'POST',\n body: JSON.stringify({ table, rows }),\n });\n },\n // Demo mode only (404 otherwise). Flips the live read-only / PII / write\n // toggles on the in-memory store; independent booleans.\n demoMode(patch) {\n return Api._fetch('/api/demo/mode', {\n method: 'POST',\n body: JSON.stringify(patch),\n });\n },\n savedQueries(table) {\n const qs = table ? '?table=' + encodeURIComponent(table) : '';\n return Api._fetch('/api/saved-queries' + qs);\n },\n saveQuery(payload) {\n return Api._fetch('/api/saved-queries', {\n method: 'POST',\n body: JSON.stringify(payload),\n });\n },\n deleteSaved(id) {\n return Api._fetch('/api/saved-queries/' + encodeURIComponent(id), {\n method: 'DELETE',\n });\n },\n };\n\n // =====================================================================\n // Utilities\n // =====================================================================\n const $ = (sel, root) => (root || document).querySelector(sel);\n const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel));\n\n // SVG elements need the SVG namespace — document.createElement('svg')\n // yields an inert HTMLUnknownElement that never renders.\n const SVG_TAGS = new Set(['svg', 'path', 'circle', 'rect', 'ellipse', 'line', 'polyline', 'polygon', 'g']);\n\n function el(tag, attrs = {}, children = []) {\n const isSvg = SVG_TAGS.has(tag);\n const node = isSvg\n ? document.createElementNS('http://www.w3.org/2000/svg', tag)\n : document.createElement(tag);\n for (const [k, v] of Object.entries(attrs)) {\n if (k === 'class') {\n if (isSvg) node.setAttribute('class', v);\n else node.className = v;\n }\n else if (k === 'text') node.textContent = v;\n else if (k === 'html') node.innerHTML = v;\n else if (k.startsWith('on') && typeof v === 'function') {\n node.addEventListener(k.slice(2).toLowerCase(), v);\n } else if (k === 'style' && typeof v === 'object') {\n Object.assign(node.style, v);\n } else if (v !== undefined && v !== null && v !== false) {\n node.setAttribute(k, v === true ? '' : String(v));\n }\n }\n const list = Array.isArray(children) ? children : [children];\n for (const c of list) {\n if (c == null || c === false) continue;\n if (typeof c === 'string' || typeof c === 'number') {\n node.appendChild(document.createTextNode(String(c)));\n } else {\n node.appendChild(c);\n }\n }\n return node;\n }\n\n function escapeHtml(s) {\n if (s == null) return '';\n return String(s)\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n }\n\n function formatCount(n) {\n if (n == null) return '—';\n if (n < 1000) return String(n);\n if (n < 1_000_000) return (n / 1000).toFixed(n < 10_000 ? 1 : 0).replace(/\\.0$/, '') + 'k';\n if (n < 1_000_000_000) return (n / 1_000_000).toFixed(n < 10_000_000 ? 1 : 0).replace(/\\.0$/, '') + 'M';\n return (n / 1_000_000_000).toFixed(1).replace(/\\.0$/, '') + 'B';\n }\n\n function formatNumber(n) {\n if (n == null) return '—';\n return Number(n).toLocaleString();\n }\n\n function debounce(fn, ms) {\n let t;\n return (...args) => {\n clearTimeout(t);\n t = setTimeout(() => fn(...args), ms);\n };\n }\n\n function isTypingInInput() {\n const a = document.activeElement;\n if (!a) return false;\n const tag = a.tagName;\n return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || a.isContentEditable;\n }\n\n function isMac() {\n return navigator.platform.toLowerCase().includes('mac');\n }\n\n function toast(msg, kind) {\n const t = el('div', { class: 'toast ' + (kind || ''), text: msg });\n $('#toastWrap').appendChild(t);\n setTimeout(() => {\n t.style.transition = 'opacity 200ms';\n t.style.opacity = '0';\n setTimeout(() => t.remove(), 220);\n }, 2400);\n }\n\n async function copyToClipboard(text, label) {\n if (!text) {\n toast('Nothing to copy', 'err');\n return;\n }\n try {\n if (navigator.clipboard && navigator.clipboard.writeText) {\n await navigator.clipboard.writeText(text);\n } else {\n // Fallback for non-secure contexts / older browsers\n const ta = document.createElement('textarea');\n ta.value = text;\n ta.style.position = 'fixed';\n ta.style.left = '-9999px';\n document.body.appendChild(ta);\n ta.select();\n document.execCommand('copy');\n document.body.removeChild(ta);\n }\n toast((label || 'Copied') + ' to clipboard', 'ok');\n } catch (err) {\n toast('Copy failed: ' + (err && err.message ? err.message : 'unknown'), 'err');\n }\n }\n\n // Prefix matches: nullable columns arrive as e.g. 'number | null'.\n function isStringType(ts) {\n return typeof ts === 'string' && ts.indexOf('string') === 0;\n }\n function isNumericType(ts) {\n return typeof ts === 'string' && (ts.indexOf('number') === 0 || ts.indexOf('bigint') === 0);\n }\n function isBoolType(ts) {\n return typeof ts === 'string' && ts.indexOf('boolean') === 0;\n }\n function isDateType(ts) {\n return typeof ts === 'string' && ts.indexOf('Date') === 0;\n }\n\n // `isNull` / `isNotNull` compile to `{ col: null }` / `{ col: { not: null } }`\n // and take no value input.\n const NULL_OPS = ['isNull', 'isNotNull'];\n\n function operatorsForType(ts) {\n const base = ['equals', 'not', 'in', 'notIn'];\n if (isStringType(ts)) return base.concat(['contains', 'startsWith', 'endsWith'], NULL_OPS);\n if (isNumericType(ts) || isDateType(ts)) return base.concat(['lt', 'lte', 'gt', 'gte'], NULL_OPS);\n return base.concat(NULL_OPS);\n }\n\n function isNullOp(op) {\n return op === 'isNull' || op === 'isNotNull';\n }\n\n // =====================================================================\n // Sidebar\n // =====================================================================\n const Sidebar = {\n render() {\n const tablesList = $('#tablesList');\n const enumsList = $('#enumsList');\n tablesList.innerHTML = '';\n enumsList.innerHTML = '';\n\n const filter = State.sidebarFilter.toLowerCase();\n const filtered = State.tables.filter((t) =>\n !filter || t.name.toLowerCase().includes(filter)\n );\n\n if (!filtered.length) {\n tablesList.appendChild(el('div', { class: 'sidebar-empty', text: 'No tables' }));\n } else {\n for (const t of filtered) {\n const active = State.currentTable && State.currentTable.name === t.name;\n const item = el(\n 'div',\n {\n class: 'sidebar-item' + (active ? ' active' : ''),\n role: 'button',\n tabindex: '0',\n 'aria-label': 'Table ' + t.name,\n onclick: () => Router.selectTable(t.name),\n onkeydown: (e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n Router.selectTable(t.name);\n }\n },\n },\n [\n el('span', { class: 'sidebar-item-name', text: t.name }),\n el('span', { class: 'sidebar-item-count', text: formatCount(t.estimatedRows) }),\n ]\n );\n tablesList.appendChild(item);\n }\n }\n\n const enumEntries = Object.entries(State.enums || {});\n if (!enumEntries.length) {\n enumsList.appendChild(el('div', { class: 'sidebar-empty', text: 'No enums' }));\n } else {\n for (const [name, values] of enumEntries) {\n enumsList.appendChild(\n el(\n 'div',\n {\n class: 'sidebar-item',\n title: (values || []).join(', '),\n },\n [\n el('span', { class: 'sidebar-item-name', text: name }),\n el('span', { class: 'sidebar-item-count', text: String((values || []).length) }),\n ]\n )\n );\n }\n }\n\n Sidebar.renderSaved();\n },\n\n renderSaved() {\n const host = $('#savedList');\n host.innerHTML = '';\n if (!State.savedQueries.length) {\n host.appendChild(el('div', { class: 'sidebar-empty', text: 'No saved queries' }));\n return;\n }\n const byTable = new Map();\n for (const q of State.savedQueries) {\n if (!byTable.has(q.table)) byTable.set(q.table, []);\n byTable.get(q.table).push(q);\n }\n const ordered = Array.from(byTable.keys()).sort();\n for (const table of ordered) {\n host.appendChild(el('div', { class: 'saved-table-header', text: table }));\n for (const q of byTable.get(table)) {\n const row = el('div', { class: 'saved-query', onclick: () => SavedQueries.load(q) }, [\n el('span', { class: 'saved-query-name', text: q.name, title: q.name }),\n el('span', { class: 'saved-kind', text: q.kind }),\n el('button', {\n class: 'saved-query-del icon-btn',\n 'aria-label': 'Delete saved query',\n onclick: (e) => {\n e.stopPropagation();\n SavedQueries.remove(q.id);\n },\n text: '×',\n }),\n ]);\n host.appendChild(row);\n }\n }\n },\n\n bind() {\n $('#sidebarSearch').addEventListener('input', (e) => {\n State.sidebarFilter = e.target.value;\n Sidebar.render();\n });\n $$('.sidebar-group-header').forEach((h) => {\n h.addEventListener('click', () => {\n const g = document.getElementById(h.dataset.group);\n if (g) g.classList.toggle('collapsed');\n });\n });\n\n // Resizer\n const resizer = $('#sidebarResizer');\n const sidebar = $('#sidebar');\n let startX = 0;\n let startW = 0;\n const onMove = (e) => {\n const dx = e.clientX - startX;\n const next = Math.min(480, Math.max(200, startW + dx));\n document.documentElement.style.setProperty('--sidebar-w', next + 'px');\n };\n const onUp = () => {\n resizer.classList.remove('dragging');\n document.removeEventListener('mousemove', onMove);\n document.removeEventListener('mouseup', onUp);\n document.body.style.userSelect = '';\n const cur = getComputedStyle(document.documentElement).getPropertyValue('--sidebar-w');\n Storage.set('sidebarW', cur.trim());\n };\n resizer.addEventListener('mousedown', (e) => {\n startX = e.clientX;\n startW = sidebar.getBoundingClientRect().width;\n resizer.classList.add('dragging');\n document.body.style.userSelect = 'none';\n document.addEventListener('mousemove', onMove);\n document.addEventListener('mouseup', onUp);\n });\n\n $('#hamburger').addEventListener('click', () => {\n sidebar.classList.toggle('open');\n });\n },\n };\n\n // =====================================================================\n // Router / tabs\n // =====================================================================\n const Router = {\n selectTab(tab) {\n State.currentTab = tab;\n Storage.set('tab', tab);\n $$('.tab').forEach((b) => {\n const active = b.dataset.tab === tab;\n b.classList.toggle('active', active);\n b.setAttribute('aria-selected', active ? 'true' : 'false');\n });\n $$('.pane').forEach((p) =>\n p.classList.toggle('active', p.id === 'pane-' + tab)\n );\n if (tab === 'schema') SchemaPane.render();\n if (tab === 'data') DataPane.render();\n if (tab === 'builder') BuilderPane.render();\n },\n\n selectTable(name) {\n const t = State.tablesByName.get(name);\n if (!t) return;\n State.currentTable = t;\n State.data.offset = 0;\n State.data.orderBy = (t.primaryKey && t.primaryKey[0]) || (t.columns[0] && t.columns[0].name);\n State.data.dir = 'asc';\n State.data.search = '';\n $('#dataSearch').value = '';\n State.data.filters = [];\n State.data.selected.clear();\n FilterBar.render();\n Storage.set('currentTable', name);\n Sidebar.render();\n if (State.currentTab === 'data') DataPane.loadAndRender();\n if (State.currentTab === 'schema') SchemaPane.render();\n if (State.currentTab === 'builder') {\n BuilderPane.resetForTable(name);\n }\n // always close mobile sidebar after selecting\n $('#sidebar').classList.remove('open');\n },\n\n bind() {\n $$('.tab').forEach((btn) => {\n btn.addEventListener('click', () => Router.selectTab(btn.dataset.tab));\n });\n },\n };\n\n // =====================================================================\n // Data pane\n // =====================================================================\n const DataPane = {\n render() {\n const t = State.currentTable;\n if (!t) {\n $('#dataTitle').textContent = '—';\n $('#dataTotal').textContent = '';\n $('#dataScroll').innerHTML = '<div class=\"empty\">Select a table to browse data.</div>';\n $('#dataRange').textContent = '—';\n $('#pageIndicator').textContent = '—';\n return;\n }\n $('#dataTitle').textContent = t.name;\n $('#dataTotal').textContent = State.data.loading ? 'loading…' : formatNumber(State.data.total) + ' rows';\n DataPane.renderSelectionBar();\n\n const host = $('#dataScroll');\n\n if (State.data.error) {\n host.innerHTML = '';\n host.appendChild(\n el('div', { class: 'error-box' }, [\n el('div', { class: 'error-box-title', text: 'Error loading data' }),\n document.createTextNode(State.data.error),\n ])\n );\n $('#dataRange').textContent = '—';\n $('#pageIndicator').textContent = '—';\n return;\n }\n\n if (State.data.loading) {\n host.innerHTML = '';\n const skel = el('div');\n for (let i = 0; i < 12; i++) {\n const row = el('div', { class: 'skeleton-row' });\n for (let c = 0; c < 5; c++) {\n row.appendChild(el('div', { class: 'skeleton-cell' }));\n }\n skel.appendChild(row);\n }\n host.appendChild(skel);\n return;\n }\n\n if (!State.data.rows.length) {\n host.innerHTML = '';\n host.appendChild(\n el('div', { class: 'empty' }, [\n el(\n 'svg',\n {\n class: 'empty-icon',\n viewBox: '0 0 24 24',\n fill: 'none',\n stroke: 'currentColor',\n 'stroke-width': '2',\n },\n [el('path', { d: 'M22 12h-6l-2 3h-4l-2-3H2' }), el('path', { d: 'M5.5 5.1 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.5-6.9A2 2 0 0 0 16.7 4H7.3a2 2 0 0 0-1.8 1.1' })]\n ),\n document.createTextNode(\n State.data.search || State.data.filters.length\n ? 'No rows match your search or filters.'\n : 'No rows.'\n ),\n ])\n );\n $('#dataRange').textContent = '0 of 0';\n $('#pageIndicator').textContent = '—';\n return;\n }\n\n host.innerHTML = '';\n const cols = State.data.columns.length\n ? State.data.columns.map((c) => c.name)\n : Object.keys(State.data.rows[0] || {});\n const piiRedacted = DataPane.redactedPiiSet();\n host.appendChild(Table.render(cols, State.data.rows, {\n sortable: true,\n sortableFor: (col) => !piiRedacted.has(col),\n orderBy: State.data.orderBy,\n dir: State.data.dir,\n actions: State.writable ? (row) => DataPane.rowActions(row) : null,\n selectable: {\n isSelected: (row) => State.data.selected.has(DataPane.rowKey(row)),\n onToggle: (row, checked) => {\n const key = DataPane.rowKey(row);\n if (checked) State.data.selected.set(key, row);\n else State.data.selected.delete(key);\n DataPane.render();\n },\n onToggleAll: (checked) => {\n for (const row of State.data.rows) {\n const key = DataPane.rowKey(row);\n if (checked) State.data.selected.set(key, row);\n else State.data.selected.delete(key);\n }\n DataPane.render();\n },\n },\n onSort: (col) => {\n if (State.data.orderBy === col) {\n State.data.dir = State.data.dir === 'asc' ? 'desc' : 'asc';\n } else {\n State.data.orderBy = col;\n State.data.dir = 'asc';\n }\n State.data.offset = 0;\n DataPane.loadAndRender();\n },\n }));\n DataPane.renderSelectionBar();\n\n const start = State.data.total ? State.data.offset + 1 : 0;\n const end = Math.min(State.data.offset + State.data.limit, State.data.total);\n $('#dataRange').textContent = start + '-' + end + ' of ' + formatNumber(State.data.total);\n const page = Math.floor(State.data.offset / State.data.limit) + 1;\n const pages = Math.max(1, Math.ceil(State.data.total / State.data.limit));\n $('#pageIndicator').textContent = 'Page ' + page + ' / ' + pages;\n $('#pagePrev').disabled = State.data.offset === 0;\n $('#pageNext').disabled = State.data.offset + State.data.limit >= State.data.total;\n },\n\n // Columns whose values are PII-redacted right now (empty when --show-pii).\n redactedPiiSet() {\n const t = State.currentTable;\n if (!t || State.showPii) return new Set();\n return new Set(t.columns.filter((c) => c.pii).map((c) => c.name));\n },\n\n // Stable identity for row selection: the PK signature when the table has\n // one, else the whole serialized row (copy still works; delete is\n // PK-only and stays disabled without one).\n rowKey(row) {\n const t = State.currentTable;\n const pks = (t && t.primaryKey) || [];\n if (pks.length) return pks.map((k) => String(row[k])).join('\u0000');\n return JSON.stringify(row);\n },\n\n renderSelectionBar() {\n const bar = $('#dataSelectionBar');\n const n = State.data.selected.size;\n bar.hidden = n === 0;\n if (n === 0) return;\n $('#selectionCount').textContent = n + ' selected';\n const t = State.currentTable;\n const canDelete = State.writable && t && (t.primaryKey || []).length > 0;\n $('#selDelete').hidden = !canDelete;\n },\n\n selectedRows() {\n return Array.from(State.data.selected.values());\n },\n\n clearSelection() {\n State.data.selected.clear();\n DataPane.render();\n },\n\n async deleteSelected() {\n const t = State.currentTable;\n if (!t || !State.writable) return;\n const pks = t.primaryKey || [];\n if (!pks.length) return;\n const rows = DataPane.selectedRows();\n if (!rows.length) return;\n const targets = rows.map((row) => {\n const where = {};\n for (const pk of pks) where[pk] = row[pk];\n return where;\n });\n const msg =\n rows.length === 1\n ? 'Delete 1 selected row? This cannot be undone.'\n : 'Delete ' + rows.length + ' selected rows in one transaction? This cannot be undone.';\n if (!window.confirm(msg)) return;\n try {\n const res = await Api.rowDeleteBulk(t.name, targets);\n State.data.selected.clear();\n toast('Deleted ' + (res.rowCount || targets.length) + ' rows', 'ok');\n DataPane.loadAndRender();\n } catch (err) {\n toast('Delete failed: ' + (err && err.message ? err.message : 'unknown'), 'err');\n }\n },\n\n // Per-row edit/delete controls (write mode only).\n rowActions(row) {\n const wrap = el('div', { class: 'row-actions' });\n wrap.appendChild(\n el('button', {\n class: 'row-action-btn',\n text: 'Edit',\n onclick: (e) => {\n e.stopPropagation();\n RowEditor.openEdit(row);\n },\n })\n );\n wrap.appendChild(\n el('button', {\n class: 'row-action-btn danger',\n text: 'Delete',\n onclick: (e) => {\n e.stopPropagation();\n DataPane.deleteRow(row);\n },\n })\n );\n return wrap;\n },\n\n async deleteRow(row) {\n const t = State.currentTable;\n if (!t || !State.writable) return;\n const pks = t.primaryKey || [];\n if (!pks.length) {\n toast('Table has no primary key; cannot delete a single row', 'err');\n return;\n }\n const where = {};\n for (const pk of pks) where[pk] = row[pk];\n const label = pks.map((k) => k + '=' + row[k]).join(', ');\n if (!window.confirm('Delete row where ' + label + '? This cannot be undone.')) return;\n try {\n await Api.rowDelete(t.name, where);\n toast('Row deleted', 'ok');\n DataPane.loadAndRender();\n } catch (err) {\n toast('Delete failed: ' + (err && err.message ? err.message : 'unknown'), 'err');\n }\n },\n\n async loadAndRender() {\n const t = State.currentTable;\n if (!t) return;\n State.data.loading = true;\n State.data.error = null;\n DataPane.render();\n try {\n const activeFilters = FilterBar.activeFilters();\n const params = {\n limit: State.data.limit,\n offset: State.data.offset,\n orderBy: State.data.orderBy,\n dir: State.data.dir,\n search: State.data.search,\n filters: activeFilters.length ? JSON.stringify(activeFilters) : '',\n };\n const res = await Api.rows(t.name, params);\n State.data.rows = res.rows || [];\n State.data.columns = res.columns || [];\n State.data.total = res.total || 0;\n } catch (err) {\n State.data.error = err.message;\n State.data.rows = [];\n State.data.total = 0;\n } finally {\n State.data.loading = false;\n DataPane.render();\n }\n },\n\n bind() {\n $('#dataSearch').addEventListener(\n 'input',\n debounce((e) => {\n State.data.search = e.target.value;\n State.data.offset = 0;\n DataPane.loadAndRender();\n }, 240)\n );\n $('#dataRefresh').addEventListener('click', () => DataPane.loadAndRender());\n $('#dataInsert').addEventListener('click', () => RowEditor.openInsert());\n $('#dataPaste').addEventListener('click', () => PasteRows.open());\n $('#dataExport').addEventListener('click', () => Exporter.open());\n $('#dataFilterAdd').addEventListener('click', () => FilterBar.add());\n $('#selCopyJson').addEventListener('click', () =>\n copyToClipboard(JSON.stringify(DataPane.selectedRows(), null, 2), 'Selection JSON copied')\n );\n $('#selCopyCsv').addEventListener('click', () =>\n copyToClipboard(toCsv(DataPane.selectedRows()), 'Selection CSV copied')\n );\n $('#selDelete').addEventListener('click', () => DataPane.deleteSelected());\n $('#selClear').addEventListener('click', () => DataPane.clearSelection());\n const pageSize = $('#pageSize');\n pageSize.value = String(State.data.limit);\n pageSize.addEventListener('change', () => {\n State.data.limit = parseInt(pageSize.value, 10) || 50;\n State.data.offset = 0;\n Storage.set('pageSize', State.data.limit);\n DataPane.loadAndRender();\n });\n $('#pagePrev').addEventListener('click', () => {\n if (State.data.offset === 0) return;\n State.data.offset = Math.max(0, State.data.offset - State.data.limit);\n DataPane.loadAndRender();\n });\n $('#pageNext').addEventListener('click', () => {\n if (State.data.offset + State.data.limit >= State.data.total) return;\n State.data.offset += State.data.limit;\n DataPane.loadAndRender();\n });\n },\n };\n\n // =====================================================================\n // CSV serialization (RFC 4180-ish: quote when needed, double inner quotes)\n // =====================================================================\n function toCsv(rows, cols) {\n if (!rows || !rows.length) return '';\n const columns = cols && cols.length ? cols : Object.keys(rows[0]);\n const cell = (v) => {\n if (v === null || v === undefined) return '';\n const s = typeof v === 'object' ? JSON.stringify(v) : String(v);\n return /[\",\\n\\r]/.test(s) ? '\"' + s.replace(/\"/g, '\"\"') + '\"' : s;\n };\n const lines = [columns.map(cell).join(',')];\n for (const row of rows) {\n lines.push(columns.map((c) => cell(row[c])).join(','));\n }\n return lines.join('\\r\\n');\n }\n\n function downloadFile(name, mime, text) {\n const blob = new Blob([text], { type: mime });\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n a.download = name;\n document.body.appendChild(a);\n a.click();\n a.remove();\n setTimeout(() => URL.revokeObjectURL(url), 5000);\n }\n\n // =====================================================================\n // Data-tab per-column filter bar\n // =====================================================================\n const FilterBar = {\n // Columns eligible for filtering (redacted PII columns are excluded —\n // the server refuses them, so don't offer them).\n columns() {\n const t = State.currentTable;\n if (!t) return [];\n const redacted = DataPane.redactedPiiSet();\n return t.columns.filter((c) => !redacted.has(c.name));\n },\n\n opsFor(col) {\n const nullOps = col.nullable ? ['isNull', 'notNull'] : [];\n if (isBoolType(col.tsType)) return ['equals', 'not'].concat(nullOps);\n if (isNumericType(col.tsType) || isDateType(col.tsType)) {\n return ['equals', 'not', 'gt', 'gte', 'lt', 'lte'].concat(nullOps);\n }\n return ['contains', 'equals', 'not', 'gt', 'gte', 'lt', 'lte'].concat(nullOps);\n },\n\n add() {\n const cols = FilterBar.columns();\n if (!cols.length) {\n toast('No filterable columns on this table', 'err');\n return;\n }\n State.data.filters.push({ column: cols[0].name, op: FilterBar.opsFor(cols[0])[0], value: '' });\n FilterBar.render();\n },\n\n remove(idx) {\n State.data.filters.splice(idx, 1);\n FilterBar.render();\n FilterBar.apply();\n },\n\n // The filters that are complete enough to send (value present unless a\n // null-check op), with values coerced to the column's type.\n activeFilters() {\n const t = State.currentTable;\n if (!t) return [];\n const out = [];\n for (const f of State.data.filters) {\n const col = t.columns.find((c) => c.name === f.column);\n if (!col) continue;\n if (f.op === 'isNull' || f.op === 'notNull') {\n out.push({ column: f.column, op: f.op });\n continue;\n }\n if (f.value === '' || f.value == null) continue;\n let value = f.value;\n if (isNumericType(col.tsType)) {\n const n = Number(value);\n if (!Number.isFinite(n)) continue;\n value = n;\n } else if (isBoolType(col.tsType)) {\n value = value === 'true' || value === true;\n } else {\n value = String(value);\n }\n out.push({ column: f.column, op: f.op, value });\n }\n return out;\n },\n\n apply: null, // assigned below (debounced)\n\n render() {\n const bar = $('#dataFilterBar');\n bar.innerHTML = '';\n const filters = State.data.filters;\n bar.hidden = filters.length === 0;\n if (!filters.length) return;\n const t = State.currentTable;\n if (!t) return;\n const cols = FilterBar.columns();\n filters.forEach((f, idx) => {\n const row = el('div', { class: 'filter-row' });\n\n const colSel = el('select', { class: 'builder-select', 'aria-label': 'Filter column' });\n for (const c of cols) {\n const opt = el('option', { value: c.name, text: c.name });\n if (c.name === f.column) opt.selected = true;\n colSel.appendChild(opt);\n }\n colSel.addEventListener('change', () => {\n f.column = colSel.value;\n const col = cols.find((c) => c.name === f.column);\n const ops = col ? FilterBar.opsFor(col) : ['equals'];\n if (!ops.includes(f.op)) f.op = ops[0];\n FilterBar.render();\n FilterBar.apply();\n });\n row.appendChild(colSel);\n\n const col = cols.find((c) => c.name === f.column) || cols[0];\n const ops = FilterBar.opsFor(col);\n const opSel = el('select', { class: 'builder-select', 'aria-label': 'Filter operator' });\n for (const op of ops) {\n const opt = el('option', { value: op, text: op });\n if (op === f.op) opt.selected = true;\n opSel.appendChild(opt);\n }\n opSel.addEventListener('change', () => {\n f.op = opSel.value;\n FilterBar.render();\n FilterBar.apply();\n });\n row.appendChild(opSel);\n\n const isNullOp = f.op === 'isNull' || f.op === 'notNull';\n if (isNullOp) {\n row.appendChild(el('span', {\n style: { fontFamily: 'var(--mono)', fontSize: '11px', color: 'var(--text-muted)' },\n text: 'no value needed',\n }));\n } else if (isBoolType(col.tsType)) {\n const valSel = el('select', { class: 'builder-select', 'aria-label': 'Filter value' });\n for (const v of ['true', 'false']) {\n const opt = el('option', { value: v, text: v });\n if (String(f.value) === v) opt.selected = true;\n valSel.appendChild(opt);\n }\n if (f.value === '' || f.value == null) f.value = 'true';\n valSel.addEventListener('change', () => {\n f.value = valSel.value;\n FilterBar.apply();\n });\n row.appendChild(valSel);\n } else {\n const valInput = el('input', {\n class: 'builder-input',\n type: isNumericType(col.tsType) ? 'number' : 'text',\n placeholder: isDateType(col.tsType) ? 'e.g. 2026-01-01' : 'value',\n value: f.value != null ? String(f.value) : '',\n 'aria-label': 'Filter value',\n });\n valInput.addEventListener('input', () => {\n f.value = valInput.value;\n FilterBar.apply();\n });\n row.appendChild(valInput);\n }\n\n row.appendChild(\n el('button', {\n class: 'icon-btn',\n 'aria-label': 'Remove filter',\n text: '×',\n onclick: () => FilterBar.remove(idx),\n })\n );\n bar.appendChild(row);\n });\n },\n };\n FilterBar.apply = debounce(() => {\n State.data.offset = 0;\n DataPane.loadAndRender();\n }, 260);\n\n // =====================================================================\n // Export (current page or selection) — JSON / CSV, copy or download\n // =====================================================================\n const Exporter = {\n scope() {\n const sel = DataPane.selectedRows();\n if (sel.length) return { rows: sel, label: sel.length + ' selected rows' };\n return { rows: State.data.rows, label: 'current page (' + State.data.rows.length + ' rows)' };\n },\n open() {\n const t = State.currentTable;\n if (!t || State.currentTab !== 'data') {\n toast('Open a table in the Data tab first', 'err');\n return;\n }\n if (!State.data.rows.length && !State.data.selected.size) {\n toast('Nothing to export', 'err');\n return;\n }\n $('#exportHint').textContent = 'Exports ' + Exporter.scope().label + ' of ' + t.name + '. Select rows to export a subset.';\n Modal.open('exportBackdrop');\n },\n cols() {\n return State.data.columns.length\n ? State.data.columns.map((c) => c.name)\n : Object.keys(State.data.rows[0] || {});\n },\n bind() {\n const table = () => (State.currentTable ? State.currentTable.name : 'rows');\n $('#exportCopyJson').addEventListener('click', () => {\n copyToClipboard(JSON.stringify(Exporter.scope().rows, null, 2), 'JSON copied');\n Modal.close('exportBackdrop');\n });\n $('#exportCopyCsv').addEventListener('click', () => {\n copyToClipboard(toCsv(Exporter.scope().rows, Exporter.cols()), 'CSV copied');\n Modal.close('exportBackdrop');\n });\n $('#exportDownloadJson').addEventListener('click', () => {\n downloadFile(table() + '.json', 'application/json', JSON.stringify(Exporter.scope().rows, null, 2));\n Modal.close('exportBackdrop');\n });\n $('#exportDownloadCsv').addEventListener('click', () => {\n downloadFile(table() + '.csv', 'text/csv', toCsv(Exporter.scope().rows, Exporter.cols()));\n Modal.close('exportBackdrop');\n });\n },\n };\n\n // =====================================================================\n // Paste rows (write mode): bulk insert from TSV/CSV (header row) or JSON\n // =====================================================================\n const PasteRows = {\n parsed: null,\n\n open() {\n const t = State.currentTable;\n if (!t) {\n toast('Select a table first', 'err');\n return;\n }\n if (!State.writable) return;\n PasteRows.parsed = null;\n const cols = t.columns.map((c) => c.name).join(', ');\n $('#pasteTitle').textContent = 'Paste rows into ' + t.name;\n $('#pasteHint').innerHTML =\n 'Paste tab- or comma-separated rows with a <code>header row</code> of column names, ' +\n 'or a JSON array of objects. Empty cells use the column default. Columns: <code>' +\n escapeHtml(cols) + '</code>';\n $('#pasteArea').value = '';\n $('#pastePreview').hidden = true;\n PasteRows.updatePreview();\n Modal.open('pasteBackdrop');\n },\n\n // Parse the textarea into row objects, or an error string.\n parse(text, t) {\n const trimmed = text.trim();\n if (!trimmed) return { rows: [] };\n const byName = new Map();\n for (const c of t.columns) {\n byName.set(c.name.toLowerCase(), c);\n byName.set(c.field.toLowerCase(), c);\n }\n const coerce = (col, raw) => {\n if (raw === '' || raw == null) return undefined;\n if (typeof raw !== 'string') return raw; // JSON path: already typed\n if (raw.toLowerCase() === 'null') return null;\n return coerceInput(col, raw);\n };\n\n if (trimmed.startsWith('[')) {\n let arr;\n try {\n arr = JSON.parse(trimmed);\n } catch (err) {\n return { error: 'Invalid JSON: ' + err.message };\n }\n if (!Array.isArray(arr)) return { error: 'JSON must be an array of objects' };\n const rows = [];\n for (let i = 0; i < arr.length; i++) {\n const item = arr[i];\n if (!item || typeof item !== 'object' || Array.isArray(item)) {\n return { error: 'Row ' + (i + 1) + ' is not an object' };\n }\n const row = {};\n for (const [k, v] of Object.entries(item)) {\n const col = byName.get(String(k).toLowerCase());\n if (!col) return { error: 'Row ' + (i + 1) + ': unknown column \"' + k + '\"' };\n if (v !== undefined) row[col.name] = v;\n }\n if (!Object.keys(row).length) return { error: 'Row ' + (i + 1) + ' has no values' };\n rows.push(row);\n }\n return { rows };\n }\n\n // Delimited text: tab wins if present (spreadsheet paste), else comma.\n const lines = trimmed.split(/\\r?\\n/).filter((l) => l.length > 0);\n if (lines.length < 2) {\n return { error: 'Need a header row plus at least one data row (or paste a JSON array)' };\n }\n const delim = lines[0].includes('\\t') ? '\\t' : ',';\n const split = (line) => PasteRows.splitDelimited(line, delim);\n const header = split(lines[0]).map((h) => h.trim());\n const colsForHeader = [];\n for (const h of header) {\n const col = byName.get(h.toLowerCase());\n if (!col) return { error: 'Unknown column \"' + h + '\" in header row' };\n colsForHeader.push(col);\n }\n const rows = [];\n for (let i = 1; i < lines.length; i++) {\n const cells = split(lines[i]);\n if (cells.length > colsForHeader.length) {\n return { error: 'Row ' + i + ' has ' + cells.length + ' cells but the header has ' + colsForHeader.length };\n }\n const row = {};\n try {\n for (let cIdx = 0; cIdx < cells.length; cIdx++) {\n const v = coerce(colsForHeader[cIdx], cells[cIdx].trim());\n if (v !== undefined) row[colsForHeader[cIdx].name] = v;\n }\n } catch (err) {\n return { error: 'Row ' + i + ': ' + (err && err.message ? err.message : 'invalid value') };\n }\n if (!Object.keys(row).length) return { error: 'Row ' + i + ' has no values' };\n rows.push(row);\n }\n return { rows };\n },\n\n // Minimal delimited-line splitter with double-quote support.\n splitDelimited(line, delim) {\n const out = [];\n let cur = '';\n let inQ = false;\n for (let i = 0; i < line.length; i++) {\n const ch = line[i];\n if (inQ) {\n if (ch === '\"' && line[i + 1] === '\"') {\n cur += '\"';\n i++;\n } else if (ch === '\"') {\n inQ = false;\n } else {\n cur += ch;\n }\n } else if (ch === '\"' && cur === '') {\n inQ = true;\n } else if (ch === delim) {\n out.push(cur);\n cur = '';\n } else {\n cur += ch;\n }\n }\n out.push(cur);\n return out;\n },\n\n updatePreview() {\n const t = State.currentTable;\n const preview = $('#pastePreview');\n const confirm = $('#pasteConfirm');\n if (!t) return;\n const result = PasteRows.parse($('#pasteArea').value, t);\n PasteRows.parsed = result.rows || null;\n if (result.error) {\n preview.hidden = false;\n preview.className = 'paste-preview err';\n preview.textContent = result.error;\n confirm.disabled = true;\n confirm.textContent = 'Insert 0 rows';\n return;\n }\n const n = result.rows.length;\n preview.hidden = n === 0;\n preview.className = 'paste-preview';\n if (n > 0) {\n const shown = result.rows.slice(0, 3).map((r) => JSON.stringify(r)).join('\\n');\n preview.textContent = n + ' rows parsed:\\n' + shown + (n > 3 ? '\\n…' : '');\n }\n confirm.disabled = n === 0 || n > 500;\n confirm.textContent = n > 500 ? 'Too many rows (max 500)' : 'Insert ' + n + ' row' + (n === 1 ? '' : 's');\n },\n\n async confirm() {\n const t = State.currentTable;\n const rows = PasteRows.parsed;\n if (!t || !rows || !rows.length) return;\n try {\n const res = await Api.rowInsertBulk(t.name, rows);\n Modal.close('pasteBackdrop');\n toast('Inserted ' + (res.rowCount || rows.length) + ' rows', 'ok');\n DataPane.loadAndRender();\n } catch (err) {\n const preview = $('#pastePreview');\n preview.hidden = false;\n preview.className = 'paste-preview err';\n preview.textContent = 'Insert failed (nothing was written): ' + (err && err.message ? err.message : 'unknown');\n }\n },\n\n bind() {\n $('#pasteArea').addEventListener('input', debounce(() => PasteRows.updatePreview(), 180));\n $('#pasteConfirm').addEventListener('click', () => PasteRows.confirm());\n },\n };\n\n // =====================================================================\n // Shared data table renderer\n // =====================================================================\n const Table = {\n render(cols, rows, opts = {}) {\n const table = el('table', { class: 'data' });\n const thead = el('thead');\n const headRow = el('tr');\n\n // Selection column (checkbox per row + select-all header).\n if (opts.selectable) {\n const allTh = el('th', { class: 'sel-col no-sort', scope: 'col' });\n const allCb = el('input', {\n type: 'checkbox',\n 'aria-label': 'Select all rows on this page',\n });\n allCb.checked = rows.length > 0 && rows.every((r) => opts.selectable.isSelected(r));\n allCb.addEventListener('change', () => opts.selectable.onToggleAll(allCb.checked));\n allTh.appendChild(allCb);\n headRow.appendChild(allTh);\n }\n\n for (const c of cols) {\n const canSort = opts.sortable && (!opts.sortableFor || opts.sortableFor(c));\n const attrs = {\n class: opts.sortable ? (canSort ? 'sortable' : 'no-sort') : '',\n scope: 'col',\n };\n if (canSort) {\n attrs.onclick = () => opts.onSort && opts.onSort(c);\n } else if (opts.sortable) {\n attrs.title = 'Sorting disabled while this PII column is redacted';\n }\n const th = el('th', attrs);\n th.appendChild(document.createTextNode(c));\n if (canSort && opts.orderBy === c) {\n th.appendChild(el('span', { class: 'sort-ind', text: opts.dir === 'asc' ? '▲' : '▼' }));\n th.setAttribute('aria-sort', opts.dir === 'asc' ? 'ascending' : 'descending');\n }\n headRow.appendChild(th);\n }\n if (opts.actions) {\n headRow.appendChild(el('th', { scope: 'col', text: '' }));\n }\n thead.appendChild(headRow);\n table.appendChild(thead);\n\n const tbody = el('tbody');\n for (const row of rows) {\n const tr = el('tr');\n if (opts.selectable && opts.selectable.isSelected(row)) tr.classList.add('selected');\n if (opts.selectable) {\n const td = el('td', { class: 'sel-col' });\n const cb = el('input', { type: 'checkbox', 'aria-label': 'Select row' });\n cb.checked = opts.selectable.isSelected(row);\n cb.addEventListener('change', () => opts.selectable.onToggle(row, cb.checked));\n td.appendChild(cb);\n tr.appendChild(td);\n }\n for (const c of cols) {\n const val = row[c];\n tr.appendChild(Table.renderCell(val));\n }\n if (opts.actions) {\n const td = el('td');\n td.appendChild(opts.actions(row));\n tr.appendChild(td);\n }\n tbody.appendChild(tr);\n }\n table.appendChild(tbody);\n return table;\n },\n\n renderCell(val) {\n const td = el('td');\n if (val === null || val === undefined) {\n td.className = 'cell-null';\n td.textContent = 'null';\n td.title = 'null';\n td.dataset.raw = 'null';\n return td;\n }\n if (typeof val === 'number') {\n td.className = 'cell-number';\n td.textContent = String(val);\n td.title = String(val);\n td.dataset.raw = String(val);\n return td;\n }\n if (typeof val === 'boolean') {\n td.className = 'cell-bool';\n td.textContent = String(val);\n td.title = String(val);\n td.dataset.raw = String(val);\n return td;\n }\n if (val && typeof val === 'object') {\n td.className = 'cell-json';\n const str = JSON.stringify(val);\n const preview = str.length > 48 ? str.slice(0, 45) + '…' : str;\n td.textContent = preview;\n td.title = 'JSON (double-click to copy, expand to view)';\n td.dataset.raw = str;\n const btn = el('button', {\n class: 'expand-btn',\n text: 'expand',\n onclick: (e) => {\n e.stopPropagation();\n JsonModal.open(val);\n },\n });\n td.appendChild(btn);\n return td;\n }\n const s = String(val);\n td.textContent = s;\n td.title = s;\n td.dataset.raw = s;\n return td;\n },\n };\n\n // Double-click any data cell to copy its raw value (delegated once so the\n // handler survives every re-render).\n document.addEventListener('dblclick', (e) => {\n const td = e.target && e.target.closest ? e.target.closest('table.data td') : null;\n if (!td || td.classList.contains('sel-col')) return;\n if (e.target.closest('button, input, a')) return;\n const text = td.dataset.raw != null ? td.dataset.raw : td.textContent;\n copyToClipboard(text, 'Cell value copied');\n });\n\n // =====================================================================\n // Schema pane\n // =====================================================================\n const SchemaPane = {\n render() {\n const t = State.currentTable;\n const host = $('#schemaScroll');\n if (!t) {\n $('#schemaTitle').textContent = '—';\n $('#schemaSub').textContent = '';\n host.innerHTML = '<div class=\"empty\">Select a table to inspect its schema.</div>';\n return;\n }\n $('#schemaTitle').textContent = t.name;\n $('#schemaSub').textContent =\n (t.columns.length + ' columns · ' + (t.relations || []).length + ' relations');\n host.innerHTML = '';\n\n // Columns\n const colsSection = el('section', { class: 'schema-section' });\n colsSection.appendChild(el('h2', { text: 'Columns' }));\n const colTable = el('table', { class: 'col-table' });\n const colHead = el('thead');\n colHead.appendChild(\n el('tr', {}, ['Name', 'TS', 'PG', 'Attributes'].map((h) => el('th', { text: h })))\n );\n colTable.appendChild(colHead);\n const colBody = el('tbody');\n for (const c of t.columns) {\n const tr = el('tr');\n tr.appendChild(\n el('td', {}, [\n el('span', {\n class: 'col-name' + (c.isPrimaryKey ? ' pk' : ''),\n text: c.name,\n }),\n ])\n );\n tr.appendChild(el('td', {}, [el('span', { class: 'col-ts', text: c.tsType || '—' })]));\n tr.appendChild(el('td', {}, [el('span', { class: 'col-pg', text: c.pgType || '—' })]));\n const attrs = el('td');\n if (c.isPrimaryKey) attrs.appendChild(el('span', { class: 'badge pk', text: 'PK' }));\n if (!c.nullable) attrs.appendChild(el('span', { class: 'badge nn', text: 'NOT NULL' }));\n else attrs.appendChild(el('span', { class: 'badge nullable', text: 'nullable' }));\n if (c.hasDefault) attrs.appendChild(el('span', { class: 'badge def', text: 'default' }));\n if (c.pii) attrs.appendChild(el('span', { class: 'badge pii', text: 'PII' }));\n tr.appendChild(attrs);\n colBody.appendChild(tr);\n }\n colTable.appendChild(colBody);\n colsSection.appendChild(colTable);\n host.appendChild(colsSection);\n\n // Relations\n const rels = t.relations || [];\n if (rels.length) {\n const relsSection = el('section', { class: 'schema-section' });\n relsSection.appendChild(el('h2', { text: 'Relations' }));\n const relTable = el('table', { class: 'col-table' });\n const relHead = el('thead');\n relHead.appendChild(\n el('tr', {}, ['Name', 'Type', 'Target', 'Keys'].map((h) => el('th', { text: h })))\n );\n relTable.appendChild(relHead);\n const relBody = el('tbody');\n for (const r of rels) {\n const tr = el('tr');\n tr.appendChild(el('td', {}, [el('span', { class: 'col-rel-name', text: r.name })]));\n tr.appendChild(el('td', {}, [el('span', { class: 'badge rel-type', text: r.type })]));\n tr.appendChild(el('td', {}, [el('span', { class: 'col-name', text: r.to })]));\n tr.appendChild(\n el('td', {}, [\n el('span', {\n class: 'col-pg',\n text: (r.foreignKey || '—') + ' → ' + (r.referenceKey || '—'),\n }),\n ])\n );\n relBody.appendChild(tr);\n }\n relTable.appendChild(relBody);\n relsSection.appendChild(relTable);\n host.appendChild(relsSection);\n }\n\n // CREATE TABLE\n const details = el('details', { class: 'schema-details' });\n details.appendChild(el('summary', { text: 'CREATE TABLE SQL' }));\n const pre = el('pre');\n pre.innerHTML = SchemaPane.renderCreateTable(t);\n details.appendChild(pre);\n host.appendChild(details);\n },\n\n renderCreateTable(t) {\n const lines = [];\n lines.push(\n '<span class=\"sql-kw\">CREATE TABLE</span> <span class=\"sql-ident\">\"' +\n escapeHtml(t.name) +\n '\"</span> ('\n );\n const colLines = t.columns.map((c) => {\n let line = ' <span class=\"sql-ident\">\"' + escapeHtml(c.name) + '\"</span> ';\n line += '<span class=\"sql-type\">' + escapeHtml(c.pgType || 'text') + '</span>';\n if (!c.nullable) line += ' <span class=\"sql-kw\">NOT NULL</span>';\n if (c.hasDefault) line += ' <span class=\"sql-kw\">DEFAULT</span> <span class=\"sql-str\">…</span>';\n return line;\n });\n if (t.primaryKey && t.primaryKey.length) {\n colLines.push(\n ' <span class=\"sql-kw\">PRIMARY KEY</span> (' +\n t.primaryKey.map((k) => '<span class=\"sql-ident\">\"' + escapeHtml(k) + '\"</span>').join(', ') +\n ')'\n );\n }\n lines.push(colLines.join(',\\n'));\n lines.push(');');\n return lines.join('\\n');\n },\n };\n\n // =====================================================================\n // Builder pane\n // =====================================================================\n const BuilderPane = {\n resetForTable(tableName) {\n State.builder.table = tableName;\n State.builder.args = emptyBuilderArgs();\n State.builder.result = null;\n State.builder.error = null;\n State.builder.validation = {};\n BuilderPane.render();\n },\n\n findTable(name) {\n return State.tablesByName.get(name);\n },\n\n render() {\n const host = $('#builderLeft');\n host.innerHTML = '';\n if (!State.tables.length) {\n host.appendChild(el('div', { class: 'empty', text: 'Loading schema…' }));\n BuilderPane.updatePreview();\n return;\n }\n if (!State.builder.table) {\n State.builder.table =\n (State.currentTable && State.currentTable.name) ||\n (State.tables[0] && State.tables[0].name);\n }\n $('#builderSub').textContent = 'Visual findMany composer';\n\n // Table picker\n const tablePick = el('div', { class: 'builder-section' }, [\n el('div', { class: 'builder-section-title' }, [el('h3', { text: 'Table' })]),\n (() => {\n const sel = el('select', { class: 'builder-select', id: 'builderTable' });\n for (const t of State.tables) {\n sel.appendChild(el('option', { value: t.name, text: t.name }));\n }\n sel.value = State.builder.table;\n sel.addEventListener('change', () => {\n BuilderPane.resetForTable(sel.value);\n });\n return sel;\n })(),\n ]);\n host.appendChild(tablePick);\n\n const tableMeta = BuilderPane.findTable(State.builder.table);\n if (!tableMeta) {\n host.appendChild(el('div', { class: 'error-box', text: 'Unknown table.' }));\n return;\n }\n\n // WHERE\n host.appendChild(BuilderPane.renderWhere(State.builder.args, tableMeta, []));\n // WITH\n host.appendChild(BuilderPane.renderWith(State.builder.args, tableMeta, []));\n // ORDER BY / LIMIT\n host.appendChild(BuilderPane.renderOrderLimit(State.builder.args, tableMeta));\n // SELECT / OMIT\n host.appendChild(BuilderPane.renderSelectOmit(State.builder.args, tableMeta));\n\n BuilderPane.updatePreview();\n BuilderPane.updateRunButton();\n },\n\n renderWhere(args, tableMeta, path) {\n const section = el('div', { class: 'builder-section' });\n const header = el('div', { class: 'builder-section-title' }, [\n el('h3', { text: 'where' }),\n (() => {\n const pick = el('div', { class: 'combinator-pick' });\n for (const c of ['AND', 'OR', 'NOT']) {\n pick.appendChild(\n el('button', {\n class: args.combinator === c ? 'active' : '',\n text: c,\n onclick: () => {\n args.combinator = c;\n BuilderPane.render();\n },\n })\n );\n }\n return pick;\n })(),\n ]);\n section.appendChild(header);\n\n const wrap = el('div', { class: 'where-clauses' });\n args.where.forEach((clause, idx) => {\n wrap.appendChild(BuilderPane.renderClause(args, clause, idx, tableMeta));\n });\n section.appendChild(wrap);\n\n const addRow = el('div', { class: 'clause-row', style: { marginTop: '8px' } }, [\n el('button', {\n class: 'btn btn-sm',\n text: '+ Clause',\n onclick: () => {\n args.where.push({\n column: (tableMeta.columns[0] || {}).name || '',\n op: 'equals',\n value: '',\n insensitive: false,\n });\n BuilderPane.render();\n },\n }),\n ]);\n section.appendChild(addRow);\n\n return section;\n },\n\n renderClause(args, clause, idx, tableMeta) {\n const row = el('div', { class: 'where-clause' });\n // column select\n const colSel = el('select', { class: 'builder-select' });\n for (const c of tableMeta.columns) {\n const opt = el('option', { value: c.name, text: c.name });\n if (c.name === clause.column) opt.selected = true;\n colSel.appendChild(opt);\n }\n colSel.addEventListener('change', () => {\n clause.column = colSel.value;\n const col = tableMeta.columns.find((c) => c.name === clause.column);\n const ops = col ? operatorsForType(col.tsType) : ['equals'];\n if (!ops.includes(clause.op)) clause.op = ops[0];\n BuilderPane.render();\n });\n row.appendChild(colSel);\n\n // op select\n const col = tableMeta.columns.find((c) => c.name === clause.column);\n const ops = col ? operatorsForType(col.tsType) : ['equals'];\n const opSel = el('select', { class: 'builder-select' });\n for (const op of ops) {\n const opt = el('option', { value: op, text: op });\n if (op === clause.op) opt.selected = true;\n opSel.appendChild(opt);\n }\n opSel.addEventListener('change', () => {\n clause.op = opSel.value;\n BuilderPane.render();\n });\n row.appendChild(opSel);\n\n // value input (null-check ops take no value)\n if (isNullOp(clause.op)) {\n row.appendChild(\n el('span', {\n style: { fontFamily: 'var(--mono)', fontSize: '11px', color: 'var(--text-muted)' },\n text: 'no value needed',\n })\n );\n } else {\n const valInput = el('input', {\n class: 'builder-input',\n type: col && isNumericType(col.tsType) ? 'number' : 'text',\n placeholder: 'value',\n value: clause.value != null ? String(clause.value) : '',\n });\n valInput.addEventListener('input', () => {\n clause.value = valInput.value;\n BuilderPane.updatePreview();\n BuilderPane.updateRunButton();\n });\n row.appendChild(valInput);\n }\n\n // remove\n const rm = el('button', {\n class: 'btn-ghost icon-btn',\n 'aria-label': 'Remove clause',\n text: '×',\n onclick: () => {\n args.where.splice(idx, 1);\n BuilderPane.render();\n },\n });\n row.appendChild(rm);\n\n // insensitive toggle for string cols\n if (col && isStringType(col.tsType) && ['contains', 'startsWith', 'endsWith', 'equals'].includes(clause.op)) {\n const ci = el('div', { class: 'clause-row', style: { gridColumn: '1 / -1' } }, [\n el(\n 'button',\n {\n class: 'chip' + (clause.insensitive ? ' active' : ''),\n text: 'mode: insensitive',\n onclick: () => {\n clause.insensitive = !clause.insensitive;\n BuilderPane.render();\n },\n },\n []\n ),\n ]);\n const both = el('div', { class: 'builder-field' });\n both.appendChild(row);\n both.appendChild(ci);\n return both;\n }\n return row;\n },\n\n renderWith(args, tableMeta, path) {\n const section = el('div', { class: 'builder-section' });\n section.appendChild(\n el('div', { class: 'builder-section-title' }, [el('h3', { text: 'with (relations)' })])\n );\n const rels = tableMeta.relations || [];\n // Depth guard — the engine caps `with` nesting at depth 10\n // (CircularRelationError). Stop offering deeper expansion before then so\n // the UI can never compose a query the engine will reject.\n if (path.length >= 8) {\n section.appendChild(\n el('div', { class: 'field-hint', style: { color: 'var(--text-muted)' }, text: 'Max relation depth reached.' })\n );\n return section;\n }\n if (!rels.length) {\n section.appendChild(\n el('div', { class: 'field-hint', style: { color: 'var(--text-muted)' }, text: 'No relations.' })\n );\n return section;\n }\n const wrap = el('div', { class: 'with-rels' });\n for (const rel of rels) {\n const existing = args.with.find((w) => w.relation === rel.name);\n const active = !!existing;\n const relBox = el('div', { class: 'with-rel' + (existing && existing.expanded ? ' expanded' : '') });\n const head = el('div', { class: 'with-rel-header' }, [\n el('div', {}, [\n el('span', { class: 'chev', text: '›' }),\n el('span', { class: 'with-rel-name', text: ' ' + rel.name }),\n el('span', { class: 'with-rel-meta', text: ' · ' + rel.type + ' → ' + rel.to }),\n ]),\n el(\n 'button',\n {\n class: 'chip' + (active ? ' active' : ''),\n text: active ? 'included' : 'include',\n onclick: (e) => {\n e.stopPropagation();\n if (active) {\n args.with = args.with.filter((w) => w.relation !== rel.name);\n } else {\n args.with.push({\n relation: rel.name,\n expanded: true,\n args: emptyBuilderArgs(),\n });\n }\n BuilderPane.render();\n },\n },\n []\n ),\n ]);\n head.addEventListener('click', () => {\n if (existing) {\n existing.expanded = !existing.expanded;\n BuilderPane.render();\n }\n });\n relBox.appendChild(head);\n\n if (existing && existing.expanded) {\n const targetMeta = BuilderPane.findTable(rel.to);\n const body = el('div', { class: 'with-rel-body' });\n if (!targetMeta) {\n body.appendChild(el('div', { class: 'field-hint', text: 'Unknown target table ' + rel.to }));\n } else {\n // Same controls as the top level, applied to this relation's args —\n // this is the recursive drill-down: pick its fields, filter it, order\n // it, and expand its own relations to arbitrary depth.\n // orderBy/limit only apply to to-many relations; a belongsTo/hasOne\n // always resolves to a single row, so we hide those controls for it.\n const toMany = rel.type === 'hasMany' || rel.type === 'manyToMany';\n body.appendChild(BuilderPane.renderSelectOmit(existing.args, targetMeta));\n body.appendChild(BuilderPane.renderWhere(existing.args, targetMeta, path.concat(rel.name)));\n if (toMany) body.appendChild(BuilderPane.renderOrderLimit(existing.args, targetMeta));\n body.appendChild(BuilderPane.renderWith(existing.args, targetMeta, path.concat(rel.name)));\n }\n relBox.appendChild(body);\n }\n wrap.appendChild(relBox);\n }\n section.appendChild(wrap);\n return section;\n },\n\n renderOrderLimit(args, tableMeta) {\n const section = el('div', { class: 'builder-section' });\n section.appendChild(\n el('div', { class: 'builder-section-title' }, [el('h3', { text: 'orderBy / limit' })])\n );\n\n const row = el('div', { style: { display: 'grid', gridTemplateColumns: '1fr 120px 100px', gap: '8px' } });\n\n const colSel = el('select', { class: 'builder-select' });\n colSel.appendChild(el('option', { value: '', text: '— none —' }));\n for (const c of tableMeta.columns) {\n const opt = el('option', { value: c.name, text: c.name });\n if (args.orderBy && args.orderBy.column === c.name) opt.selected = true;\n colSel.appendChild(opt);\n }\n colSel.addEventListener('change', () => {\n args.orderBy = args.orderBy || { column: null, dir: 'asc' };\n args.orderBy.column = colSel.value || null;\n BuilderPane.updatePreview();\n });\n row.appendChild(colSel);\n\n const dirSel = el('select', { class: 'builder-select' });\n for (const d of ['asc', 'desc']) {\n const opt = el('option', { value: d, text: d });\n if (args.orderBy && args.orderBy.dir === d) opt.selected = true;\n dirSel.appendChild(opt);\n }\n dirSel.addEventListener('change', () => {\n args.orderBy = args.orderBy || { column: null, dir: 'asc' };\n args.orderBy.dir = dirSel.value;\n BuilderPane.updatePreview();\n });\n row.appendChild(dirSel);\n\n const limInput = el('input', {\n class: 'builder-input',\n type: 'number',\n min: '1',\n max: '10000',\n value: args.limit != null ? String(args.limit) : '',\n placeholder: 'limit',\n });\n limInput.addEventListener('input', () => {\n const n = parseInt(limInput.value, 10);\n args.limit = Number.isFinite(n) && n > 0 ? n : null;\n BuilderPane.updatePreview();\n BuilderPane.updateRunButton();\n });\n row.appendChild(limInput);\n section.appendChild(row);\n return section;\n },\n\n renderSelectOmit(args, tableMeta) {\n const section = el('div', { class: 'builder-section' });\n section.appendChild(\n el('div', { class: 'builder-section-title' }, [el('h3', { text: 'select / omit' })])\n );\n const chipsSel = el('div', { class: 'chip-row' });\n chipsSel.appendChild(\n el(\n 'span',\n { style: { fontFamily: 'var(--mono)', fontSize: '10px', color: 'var(--text-muted)', marginRight: '4px' } },\n ['select:']\n )\n );\n for (const c of tableMeta.columns) {\n const active = args.select.includes(c.name);\n chipsSel.appendChild(\n el('button', {\n class: 'chip' + (active ? ' active' : ''),\n text: c.name,\n onclick: () => {\n if (active) args.select = args.select.filter((x) => x !== c.name);\n else {\n args.select.push(c.name);\n args.omit = args.omit.filter((x) => x !== c.name);\n }\n BuilderPane.render();\n },\n })\n );\n }\n section.appendChild(chipsSel);\n\n const chipsOm = el('div', { class: 'chip-row', style: { marginTop: '8px' } });\n chipsOm.appendChild(\n el(\n 'span',\n { style: { fontFamily: 'var(--mono)', fontSize: '10px', color: 'var(--text-muted)', marginRight: '4px' } },\n ['omit:']\n )\n );\n for (const c of tableMeta.columns) {\n const active = args.omit.includes(c.name);\n chipsOm.appendChild(\n el('button', {\n class: 'chip' + (active ? ' active' : ''),\n text: c.name,\n onclick: () => {\n if (active) args.omit = args.omit.filter((x) => x !== c.name);\n else {\n args.omit.push(c.name);\n args.select = args.select.filter((x) => x !== c.name);\n }\n BuilderPane.render();\n },\n })\n );\n }\n section.appendChild(chipsOm);\n return section;\n },\n\n // ---------- Spec assembly / validation ----------\n buildFindManyArgs(localArgs, tableMeta) {\n const out = {};\n const whereObj = BuilderPane.assembleWhere(localArgs, tableMeta);\n if (whereObj) out.where = whereObj;\n if (localArgs.with && localArgs.with.length) {\n out.with = {};\n for (const w of localArgs.with) {\n const rel = (tableMeta.relations || []).find((r) => r.name === w.relation);\n if (!rel) continue;\n const childMeta = BuilderPane.findTable(rel.to);\n const childArgs = childMeta ? BuilderPane.buildFindManyArgs(w.args, childMeta) : null;\n if (childArgs && Object.keys(childArgs).length) {\n out.with[w.relation] = childArgs;\n } else {\n out.with[w.relation] = true;\n }\n }\n }\n if (localArgs.orderBy && localArgs.orderBy.column) {\n out.orderBy = { [localArgs.orderBy.column]: localArgs.orderBy.dir };\n }\n if (localArgs.limit) out.limit = localArgs.limit;\n if (localArgs.select && localArgs.select.length) {\n out.select = Object.fromEntries(localArgs.select.map((c) => [c, true]));\n }\n if (localArgs.omit && localArgs.omit.length) {\n out.omit = Object.fromEntries(localArgs.omit.map((c) => [c, true]));\n }\n return out;\n },\n\n assembleWhere(localArgs, tableMeta) {\n if (!localArgs.where || !localArgs.where.length) return null;\n const clauses = [];\n for (const c of localArgs.where) {\n const col = tableMeta.columns.find((cc) => cc.name === c.column);\n if (!col) continue;\n if (isNullOp(c.op)) {\n clauses.push({ [c.column]: c.op === 'isNull' ? null : { not: null } });\n continue;\n }\n const parsed = BuilderPane.coerceValue(c.value, col.tsType, c.op);\n if (parsed === undefined) continue;\n const inner =\n c.op === 'equals'\n ? parsed\n : c.insensitive\n ? { [c.op]: parsed, mode: 'insensitive' }\n : { [c.op]: parsed };\n clauses.push({ [c.column]: inner });\n }\n if (!clauses.length) return null;\n if (clauses.length === 1 && localArgs.combinator === 'AND') return clauses[0];\n if (localArgs.combinator === 'AND') return Object.assign({}, ...clauses);\n if (localArgs.combinator === 'OR') return { OR: clauses };\n if (localArgs.combinator === 'NOT') return { NOT: clauses.length === 1 ? clauses[0] : { AND: clauses } };\n return null;\n },\n\n coerceValue(val, tsType, op) {\n if (val == null || val === '') return undefined;\n if (op === 'in' || op === 'notIn') {\n return String(val)\n .split(',')\n .map((s) => s.trim())\n .filter(Boolean)\n .map((s) => (isNumericType(tsType) ? Number(s) : s));\n }\n if (isNumericType(tsType)) {\n const n = Number(val);\n return Number.isFinite(n) ? n : undefined;\n }\n if (isBoolType(tsType)) {\n return val === 'true' || val === true;\n }\n return String(val);\n },\n\n validate() {\n const errs = {};\n if (!State.builder.table) errs.table = 'Pick a table.';\n if (State.builder.args.limit != null && State.builder.args.limit < 1) {\n errs.limit = 'Limit must be at least 1.';\n }\n State.builder.args.where.forEach((c, i) => {\n if (!c.column) {\n errs['where' + i] = 'where clause ' + (i + 1) + ': pick a column.';\n } else if (!isNullOp(c.op) && (c.value === '' || c.value == null)) {\n errs['where' + i] =\n 'where clause ' + (i + 1) + ' (' + c.column + ' ' + c.op + '): enter a value, or remove the clause.';\n }\n });\n State.builder.validation = errs;\n return Object.keys(errs).length === 0;\n },\n\n updateRunButton() {\n const ok = BuilderPane.validate();\n $('#builderRun').disabled = !ok;\n // Surface WHY Run is disabled — a grayed-out button with no explanation\n // is a dead end.\n const box = $('#builderValidation');\n const msgs = Object.values(State.builder.validation);\n box.hidden = msgs.length === 0;\n box.innerHTML = '';\n for (const m of msgs) box.appendChild(el('div', { text: m }));\n },\n\n updatePreview() {\n const host = $('#builderPreview');\n const tableMeta = BuilderPane.findTable(State.builder.table);\n if (!tableMeta) {\n host.textContent = '';\n host.dataset.rawCode = '';\n return;\n }\n const args = BuilderPane.buildFindManyArgs(State.builder.args, tableMeta);\n const code = BuilderPane.renderCode(State.builder.table, args);\n host.dataset.rawCode = code;\n host.innerHTML = highlightTs(code);\n },\n\n renderCode(tableName, args) {\n const ind = ' ';\n let out = 'await client.' + tableName + '.findMany(';\n const body = renderJsArgs(args, 1);\n if (!body) {\n out += ');';\n } else {\n out += '{\\n' + body + '\\n});';\n }\n return out;\n },\n\n async run() {\n if (!BuilderPane.validate()) return;\n const tableMeta = BuilderPane.findTable(State.builder.table);\n const args = BuilderPane.buildFindManyArgs(State.builder.args, tableMeta);\n State.builder.running = true;\n State.builder.error = null;\n BuilderPane.renderResults();\n try {\n const res = await Api.runBuilder(State.builder.table, args);\n State.builder.result = res;\n State.builder.error = null;\n } catch (err) {\n State.builder.error = err.message;\n State.builder.result = null;\n } finally {\n State.builder.running = false;\n BuilderPane.renderResults();\n }\n },\n\n // Show/hide the generated-SQL affordances. The whole point of the single\n // query strategy is that you can SEE the single query.\n syncSqlPanel() {\n const r = State.builder.result;\n const hasSql = !!(r && r.sql);\n const toggle = $('#builderSqlToggle');\n const copy = $('#builderSqlCopy');\n const pre = $('#builderSql');\n toggle.hidden = !hasSql;\n copy.hidden = !hasSql;\n if (!hasSql) {\n pre.hidden = true;\n return;\n }\n pre.textContent = r.sql;\n pre.hidden = !State.builder.showSql;\n toggle.textContent = State.builder.showSql ? 'Hide SQL' : 'View SQL';\n },\n\n renderResults() {\n const host = $('#builderResults');\n const header = $('#builderResultsHeader');\n BuilderPane.syncSqlPanel();\n if (State.builder.running) {\n header.textContent = 'Results · running…';\n host.innerHTML = '';\n const skel = el('div');\n for (let i = 0; i < 6; i++) {\n const row = el('div', { class: 'skeleton-row' });\n for (let c = 0; c < 4; c++) row.appendChild(el('div', { class: 'skeleton-cell' }));\n skel.appendChild(row);\n }\n host.appendChild(skel);\n return;\n }\n if (State.builder.error) {\n header.textContent = 'Results · error';\n host.innerHTML = '';\n host.appendChild(\n el('div', { class: 'error-box' }, [\n el('div', { class: 'error-box-title', text: 'Builder error' }),\n document.createTextNode(State.builder.error),\n ])\n );\n return;\n }\n const r = State.builder.result;\n if (!r) {\n header.textContent = 'Results';\n host.innerHTML = '<div class=\"empty\">Run to see results.</div>';\n return;\n }\n header.textContent = 'Results · ' + formatNumber(r.rowCount) + ' rows · ' + r.elapsedMs + 'ms';\n host.innerHTML = '';\n if (!r.rows || !r.rows.length) {\n host.appendChild(el('div', { class: 'empty', text: 'No rows.' }));\n return;\n }\n const cols = (r.columns || []).map((c) => c.name || c);\n const rendered = cols.length ? cols : Object.keys(r.rows[0] || {});\n host.appendChild(Table.render(rendered, r.rows));\n },\n\n openSave() {\n if (!BuilderPane.validate()) return;\n const tableMeta = BuilderPane.findTable(State.builder.table);\n const args = BuilderPane.buildFindManyArgs(State.builder.args, tableMeta);\n State.savePrompt = { kind: 'builder', args, fixedTable: State.builder.table };\n Modal.open('savePromptBackdrop');\n $('#savePromptName').value = '';\n SavePrompt.fillTables(State.builder.table);\n },\n\n bind() {\n $('#builderRun').addEventListener('click', () => BuilderPane.run());\n $('#builderSave').addEventListener('click', () => BuilderPane.openSave());\n $('#builderCopy').addEventListener('click', () => {\n const code = $('#builderPreview').dataset.rawCode || '';\n copyToClipboard(code, 'TypeScript copied');\n });\n $('#builderSqlToggle').addEventListener('click', () => {\n State.builder.showSql = !State.builder.showSql;\n Storage.set('showSql', State.builder.showSql);\n BuilderPane.syncSqlPanel();\n });\n $('#builderSqlCopy').addEventListener('click', () => {\n const r = State.builder.result;\n copyToClipboard(r && r.sql ? r.sql : '', 'SQL copied');\n });\n $('#builderReset').addEventListener('click', () => {\n if (State.builder.table) BuilderPane.resetForTable(State.builder.table);\n });\n },\n };\n\n // ---------- JS args renderer (used by builder preview) ----------\n function renderJsArgs(obj, depth) {\n if (obj == null) return '';\n const ind = ' '.repeat(depth);\n const indIn = ' '.repeat(depth + 1);\n const keys = Object.keys(obj);\n if (!keys.length) return '';\n const lines = [];\n for (const k of keys) {\n const v = obj[k];\n lines.push(ind + k + ': ' + renderJsValue(v, depth) + ',');\n }\n return lines.join('\\n');\n }\n function renderJsValue(v, depth) {\n if (v === null) return 'null';\n if (v === undefined) return 'undefined';\n if (typeof v === 'boolean') return v ? 'true' : 'false';\n if (typeof v === 'number') return String(v);\n if (typeof v === 'string') return \"'\" + v.replace(/\\\\/g, '\\\\\\\\').replace(/'/g, \"\\\\'\") + \"'\";\n if (Array.isArray(v)) {\n if (!v.length) return '[]';\n return '[' + v.map((x) => renderJsValue(x, depth)).join(', ') + ']';\n }\n if (typeof v === 'object') {\n const keys = Object.keys(v);\n if (!keys.length) return '{}';\n const ind = ' '.repeat(depth + 1);\n const close = ' '.repeat(depth);\n const inner = keys.map((k) => ind + k + ': ' + renderJsValue(v[k], depth + 1)).join(',\\n');\n return '{\\n' + inner + '\\n' + close + '}';\n }\n return String(v);\n }\n\n // ---------- TS syntax highlighter (simple tokenizer) ----------\n function highlightTs(src) {\n const KWS = new Set([\n 'await',\n 'async',\n 'const',\n 'let',\n 'var',\n 'return',\n 'if',\n 'else',\n 'true',\n 'false',\n 'null',\n 'undefined',\n 'import',\n 'from',\n 'export',\n 'new',\n ]);\n const out = [];\n let i = 0;\n while (i < src.length) {\n const ch = src[i];\n // comment\n if (ch === '/' && src[i + 1] === '/') {\n let j = i;\n while (j < src.length && src[j] !== '\\n') j++;\n out.push('<span class=\"tok-comment\">' + escapeHtml(src.slice(i, j)) + '</span>');\n i = j;\n continue;\n }\n // string\n if (ch === \"'\" || ch === '\"' || ch === '`') {\n const q = ch;\n let j = i + 1;\n while (j < src.length && src[j] !== q) {\n if (src[j] === '\\\\') j += 2;\n else j++;\n }\n j = Math.min(src.length, j + 1);\n out.push('<span class=\"tok-str\">' + escapeHtml(src.slice(i, j)) + '</span>');\n i = j;\n continue;\n }\n // number\n if (/[0-9]/.test(ch)) {\n let j = i;\n while (j < src.length && /[0-9_.]/.test(src[j])) j++;\n out.push('<span class=\"tok-num\">' + escapeHtml(src.slice(i, j)) + '</span>');\n i = j;\n continue;\n }\n // identifier / keyword\n if (/[A-Za-z_$]/.test(ch)) {\n let j = i;\n while (j < src.length && /[A-Za-z0-9_$]/.test(src[j])) j++;\n const word = src.slice(i, j);\n // key: identifier followed by colon\n let k = j;\n while (k < src.length && src[k] === ' ') k++;\n if (src[k] === ':') {\n out.push('<span class=\"tok-key\">' + escapeHtml(word) + '</span>');\n } else if (KWS.has(word)) {\n out.push('<span class=\"tok-kw\">' + escapeHtml(word) + '</span>');\n } else if (src[j] === '(' || (src[j] === '.' && false)) {\n out.push('<span class=\"tok-fn\">' + escapeHtml(word) + '</span>');\n } else {\n out.push(escapeHtml(word));\n }\n i = j;\n continue;\n }\n // punctuation\n if (/[{}[\\](),;:]/.test(ch)) {\n out.push('<span class=\"tok-punc\">' + escapeHtml(ch) + '</span>');\n i++;\n continue;\n }\n out.push(escapeHtml(ch));\n i++;\n }\n return out.join('');\n }\n\n // =====================================================================\n // Saved queries\n // =====================================================================\n const SavedQueries = {\n async load(q) {\n if (q.kind === 'builder') {\n if (q.table) {\n const t = State.tablesByName.get(q.table);\n if (t) {\n State.currentTable = t;\n Storage.set('currentTable', t.name);\n }\n }\n Router.selectTab('builder');\n // Re-hydrating from a saved args object is tricky since our internal\n // shape differs from the flat Turbine args object. For now we store\n // the args alongside the builder table and render a hint.\n State.builder.table = q.table;\n State.builder.args = SavedQueries.hydrateBuilderArgs(q.args || {}, q.table);\n BuilderPane.render();\n toast('Loaded saved builder: ' + q.name, 'ok');\n }\n },\n\n hydrateBuilderArgs(argsObj, tableName) {\n // Best-effort inverse of buildFindManyArgs for a flat where obj\n const tableMeta = State.tablesByName.get(tableName);\n const out = emptyBuilderArgs();\n if (!tableMeta) return out;\n if (argsObj.limit) out.limit = argsObj.limit;\n if (argsObj.orderBy) {\n const k = Object.keys(argsObj.orderBy)[0];\n if (k) out.orderBy = { column: k, dir: argsObj.orderBy[k] };\n }\n if (argsObj.where) {\n const w = argsObj.where;\n const collectInto = (obj) => {\n for (const [col, val] of Object.entries(obj)) {\n if (col === 'AND' || col === 'OR' || col === 'NOT') continue;\n if (val === null) {\n // { col: null } is IS NULL\n out.where.push({ column: col, op: 'isNull', value: '', insensitive: false });\n } else if (val && typeof val === 'object' && !Array.isArray(val)) {\n const op = Object.keys(val).find((k) => k !== 'mode') || 'equals';\n if (op === 'not' && val[op] === null) {\n // { col: { not: null } } is IS NOT NULL\n out.where.push({ column: col, op: 'isNotNull', value: '', insensitive: false });\n } else {\n out.where.push({\n column: col,\n op,\n value: val[op],\n insensitive: val.mode === 'insensitive',\n });\n }\n } else {\n out.where.push({ column: col, op: 'equals', value: val, insensitive: false });\n }\n }\n };\n if (w.OR) {\n out.combinator = 'OR';\n for (const c of w.OR) collectInto(c);\n } else if (w.NOT) {\n out.combinator = 'NOT';\n const inner = w.NOT;\n if (inner && typeof inner === 'object' && Array.isArray(inner.AND)) {\n for (const c of inner.AND) collectInto(c);\n } else if (inner && typeof inner === 'object') {\n collectInto(inner);\n }\n } else if (w.AND) {\n for (const c of w.AND) collectInto(c);\n } else {\n collectInto(w);\n }\n }\n if (argsObj.select) out.select = Object.keys(argsObj.select);\n if (argsObj.omit) out.omit = Object.keys(argsObj.omit);\n if (argsObj.with) {\n for (const [relName, v] of Object.entries(argsObj.with)) {\n const rel = (tableMeta.relations || []).find((r) => r.name === relName);\n if (!rel) continue;\n const childArgs = v === true ? emptyBuilderArgs() : SavedQueries.hydrateBuilderArgs(v, rel.to);\n out.with.push({ relation: relName, expanded: true, args: childArgs });\n }\n }\n return out;\n },\n\n async remove(id) {\n const q = State.savedQueries.find((x) => x.id === id);\n const label = q ? '\"' + q.name + '\"' : 'this saved query';\n if (!window.confirm('Delete ' + label + '? This cannot be undone.')) return;\n try {\n await Api.deleteSaved(id);\n State.savedQueries = State.savedQueries.filter((q) => q.id !== id);\n Sidebar.renderSaved();\n toast('Deleted saved query', 'ok');\n } catch (err) {\n toast('Delete failed: ' + err.message, 'err');\n }\n },\n\n async refresh() {\n try {\n const res = await Api.savedQueries();\n State.savedQueries = res.queries || [];\n Sidebar.renderSaved();\n } catch {\n // silent on refresh fail\n }\n },\n };\n\n // =====================================================================\n // Modals\n // =====================================================================\n const Modal = {\n open(id) {\n const m = document.getElementById(id);\n if (!m) return;\n m.classList.add('open');\n m.setAttribute('aria-hidden', 'false');\n setTimeout(() => {\n const inp = m.querySelector('input,textarea,select,button');\n if (inp) inp.focus();\n }, 20);\n },\n close(id) {\n const m = document.getElementById(id);\n if (!m) return;\n m.classList.remove('open');\n m.setAttribute('aria-hidden', 'true');\n },\n bind() {\n $$('[data-close-modal]').forEach((b) => {\n b.addEventListener('click', () => Modal.close(b.dataset.closeModal));\n });\n $$('.modal-backdrop').forEach((bd) => {\n bd.addEventListener('click', (e) => {\n if (e.target === bd) Modal.close(bd.id);\n });\n });\n },\n };\n\n const JsonModal = {\n open(val) {\n const host = $('#jsonModalBody');\n host.textContent = JSON.stringify(val, null, 2);\n Modal.open('jsonModalBackdrop');\n },\n };\n\n // =====================================================================\n // Row editor (write mode): single-row insert / edit\n // =====================================================================\n // Matches the server's redaction marker so a redacted PII cell is never\n // written back verbatim when editing a row.\n const REDACTED_MARKER = '•• redacted ••';\n\n function coerceInput(col, raw) {\n const ts = col.tsType || '';\n if (/json/i.test(col.pgType || '')) {\n try {\n return JSON.parse(raw);\n } catch (err) {\n throw new Error('Column \"' + col.name + '\": invalid JSON — ' + err.message);\n }\n }\n if (isNumericType(ts)) {\n const n = Number(raw);\n return Number.isNaN(n) ? raw : n;\n }\n if (isBoolType(ts)) return raw === 'true' || raw === '1' || raw === 't';\n return raw;\n }\n\n const RowEditor = {\n mode: 'insert',\n table: null,\n pkWhere: null,\n inputs: [],\n\n openInsert() {\n const t = State.currentTable;\n if (!t) {\n toast('Select a table first', 'err');\n return;\n }\n RowEditor.mode = 'insert';\n RowEditor.table = t;\n RowEditor.pkWhere = null;\n $('#rowEditorTitle').textContent = 'Insert into ' + t.name;\n $('#rowEditorHint').textContent = 'Leave a field blank to use its database default.';\n RowEditor.buildFields(t, {});\n RowEditor.showError(null);\n Modal.open('rowEditorBackdrop');\n },\n\n openEdit(row) {\n const t = State.currentTable;\n if (!t) return;\n const pks = t.primaryKey || [];\n if (!pks.length) {\n toast('Table has no primary key; cannot edit a single row', 'err');\n return;\n }\n RowEditor.mode = 'edit';\n RowEditor.table = t;\n const where = {};\n for (const pk of pks) where[pk] = row[pk];\n RowEditor.pkWhere = where;\n $('#rowEditorTitle').textContent = 'Edit row in ' + t.name;\n $('#rowEditorHint').textContent = 'Primary key columns are read-only. Cleared fields are left unchanged.';\n RowEditor.buildFields(t, row);\n RowEditor.showError(null);\n Modal.open('rowEditorBackdrop');\n },\n\n buildFields(t, row) {\n const host = $('#rowEditorFields');\n host.innerHTML = '';\n RowEditor.inputs = [];\n for (const c of t.columns) {\n const field = el('div', { class: 'row-editor-field' });\n const label = el('label');\n label.appendChild(document.createTextNode(c.name + ' '));\n label.appendChild(el('span', { class: 'col-pg', text: c.pgType || '' }));\n if (c.isPrimaryKey) label.appendChild(el('span', { class: 'pkflag', text: ' · PK' }));\n if (c.pii) label.appendChild(el('span', { class: 'piiflag', text: ' · PII' }));\n // Typed controls: enums and booleans get a select (no more hand-typed\n // 'true'/'t'); JSON and timestamps get format placeholders + validation.\n const ts = c.tsType || '';\n const enumValues = (State.enums && State.enums[c.pgType]) || null;\n let input;\n if (enumValues && enumValues.length) {\n input = el('select', {});\n input.appendChild(el('option', { value: '', text: '— unset —' }));\n for (const ev of enumValues) input.appendChild(el('option', { value: ev, text: ev }));\n } else if (ts.indexOf('boolean') === 0) {\n input = el('select', {});\n input.appendChild(el('option', { value: '', text: '— unset —' }));\n input.appendChild(el('option', { value: 'true', text: 'true' }));\n input.appendChild(el('option', { value: 'false', text: 'false' }));\n } else {\n input = el('input', { type: 'text' });\n if (/json/i.test(c.pgType || '')) {\n input.placeholder = 'JSON, e.g. {\"key\": \"value\"}';\n } else if (ts.indexOf('Date') === 0) {\n input.placeholder = 'ISO 8601, e.g. 2026-01-01T00:00:00Z';\n }\n }\n const v = row[c.name];\n if (RowEditor.mode === 'edit' && v !== undefined && v !== null && v !== REDACTED_MARKER) {\n input.value = typeof v === 'object' ? JSON.stringify(v) : String(v);\n } else if (RowEditor.mode === 'edit' && v === REDACTED_MARKER && input.tagName === 'INPUT') {\n input.placeholder = 'redacted · leave blank to keep the existing value';\n }\n if (RowEditor.mode === 'edit' && c.isPrimaryKey) input.disabled = true;\n field.appendChild(label);\n\n const rec = { col: c, input, isNull: false };\n // Nullable, non-PK columns get an explicit NULL toggle. Toggling it\n // disables the text input and makes collect() emit an explicit null\n // (which the write routes send as a $N param), rather than the\n // blank-means-unchanged/default semantics of an empty input.\n const nullable = c.nullable && !c.isPrimaryKey;\n if (nullable) {\n const basePlaceholder = input.placeholder;\n const nullBtn = el('button', {\n type: 'button',\n class: 'null-toggle',\n text: 'NULL',\n title: 'Set this column to NULL',\n 'aria-pressed': 'false',\n });\n nullBtn.addEventListener('click', () => {\n rec.isNull = !rec.isNull;\n nullBtn.classList.toggle('active', rec.isNull);\n nullBtn.setAttribute('aria-pressed', rec.isNull ? 'true' : 'false');\n input.disabled = rec.isNull;\n input.classList.toggle('null-set', rec.isNull);\n input.placeholder = rec.isNull ? 'NULL' : basePlaceholder;\n });\n const inputRow = el('div', { class: 'row-editor-input-row' });\n inputRow.appendChild(input);\n inputRow.appendChild(nullBtn);\n field.appendChild(inputRow);\n } else {\n field.appendChild(input);\n }\n\n host.appendChild(field);\n RowEditor.inputs.push(rec);\n }\n },\n\n collect() {\n const data = {};\n for (const rec of RowEditor.inputs) {\n const { col, input } = rec;\n if (RowEditor.mode === 'edit' && col.isPrimaryKey) continue;\n // An explicit NULL toggle wins over any typed value (including a\n // redacted PII placeholder): send a real null, never the marker.\n if (rec.isNull) {\n data[col.name] = null;\n continue;\n }\n if (input.disabled) continue;\n const raw = input.value;\n if (raw === '') continue;\n data[col.name] = coerceInput(col, raw);\n }\n return data;\n },\n\n async confirm() {\n const t = RowEditor.table;\n if (!t) return;\n let data;\n try {\n data = RowEditor.collect();\n } catch (err) {\n RowEditor.showError(err && err.message ? err.message : 'Invalid value');\n return;\n }\n if (!Object.keys(data).length) {\n RowEditor.showError(RowEditor.mode === 'insert' ? 'Enter at least one value.' : 'No changes to save.');\n return;\n }\n try {\n if (RowEditor.mode === 'insert') {\n await Api.rowInsert(t.name, data);\n toast('Row inserted', 'ok');\n } else {\n await Api.rowUpdate(t.name, RowEditor.pkWhere, data);\n toast('Row updated', 'ok');\n }\n Modal.close('rowEditorBackdrop');\n DataPane.loadAndRender();\n } catch (err) {\n RowEditor.showError(err && err.message ? err.message : 'Write failed');\n }\n },\n\n showError(msg) {\n const box = $('#rowEditorError');\n if (!msg) {\n box.hidden = true;\n box.textContent = '';\n return;\n }\n box.hidden = false;\n box.textContent = msg;\n },\n\n bind() {\n $('#rowEditorConfirm').addEventListener('click', () => RowEditor.confirm());\n },\n };\n\n const SavePrompt = {\n fillTables(preferred) {\n const sel = $('#savePromptTable');\n sel.innerHTML = '';\n for (const t of State.tables) {\n const opt = el('option', { value: t.name, text: t.name });\n sel.appendChild(opt);\n }\n const target =\n preferred ||\n (State.currentTable && State.currentTable.name) ||\n (State.tables[0] && State.tables[0].name);\n if (target) sel.value = target;\n },\n bind() {\n $('#savePromptConfirm').addEventListener('click', async () => {\n const name = $('#savePromptName').value.trim();\n // The table is locked to the builder's table — the composed args only\n // make sense against it.\n const table = State.savePrompt.fixedTable || $('#savePromptTable').value;\n if (!name || !table) {\n toast('Name and table required', 'err');\n return;\n }\n const payload = {\n table,\n name,\n kind: 'builder',\n args: State.savePrompt.args,\n };\n try {\n await Api.saveQuery(payload);\n Modal.close('savePromptBackdrop');\n toast('Query saved', 'ok');\n SavedQueries.refresh();\n } catch (err) {\n toast('Save failed: ' + err.message, 'err');\n }\n });\n },\n };\n\n // =====================================================================\n // Command palette\n // =====================================================================\n const Palette = {\n bind() {\n $('#openPalette').addEventListener('click', () => Palette.open());\n const input = $('#paletteInput');\n input.addEventListener('input', () => Palette.render());\n input.addEventListener('keydown', (e) => {\n if (e.key === 'Escape') {\n Modal.close('paletteBackdrop');\n return;\n }\n if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {\n e.preventDefault();\n const items = $$('.palette-item');\n if (!items.length) return;\n const cur = items.findIndex((n) => n.classList.contains('selected'));\n const next =\n e.key === 'ArrowDown'\n ? Math.min(items.length - 1, cur + 1)\n : Math.max(0, cur - 1);\n items.forEach((n, idx) => n.classList.toggle('selected', idx === next));\n items[next].scrollIntoView({ block: 'nearest' });\n }\n if (e.key === 'Enter') {\n const sel = $('.palette-item.selected');\n if (sel) sel.click();\n }\n });\n },\n\n open() {\n Modal.open('paletteBackdrop');\n $('#paletteInput').value = '';\n Palette.render();\n },\n\n render() {\n const q = $('#paletteInput').value.toLowerCase().trim();\n const list = $('#paletteList');\n list.innerHTML = '';\n\n const items = [];\n\n // Commands\n const cmds = [\n { name: 'Go to Query tab', kbd: ['G', 'Q'], run: () => Router.selectTab('builder') },\n { name: 'Go to Data tab', kbd: ['G', 'D'], run: () => Router.selectTab('data') },\n { name: 'Go to Schema tab', kbd: ['G', 'S'], run: () => Router.selectTab('schema') },\n { name: 'Run query', kbd: [isMac() ? '⌘' : 'Ctrl', '↵'], run: () => { Router.selectTab('builder'); setTimeout(() => BuilderPane.run(), 50); } },\n { name: 'Save query', kbd: [isMac() ? '⌘' : 'Ctrl', 'S'], run: () => { Router.selectTab('builder'); BuilderPane.openSave(); } },\n { name: 'Refresh data', kbd: ['R'], run: () => DataPane.loadAndRender() },\n { name: 'Reload schema', kbd: ['Shift', 'R'], run: () => boot(true) },\n ];\n const filteredCmds = q\n ? cmds.filter((c) => c.name.toLowerCase().includes(q))\n : cmds;\n if (filteredCmds.length) {\n list.appendChild(el('div', { class: 'palette-section', text: 'Commands' }));\n for (const c of filteredCmds) items.push(Palette.renderItem(c.name, c.kbd, c.run));\n }\n\n // Tables\n const tables = State.tables.filter(\n (t) => !q || t.name.toLowerCase().includes(q)\n );\n if (tables.length) {\n list.appendChild(el('div', { class: 'palette-section', text: 'Tables' }));\n for (const t of tables.slice(0, 50)) {\n items.push(\n Palette.renderItem(t.name, [formatCount(t.estimatedRows)], () => {\n Router.selectTable(t.name);\n Modal.close('paletteBackdrop');\n })\n );\n }\n }\n\n if (!items.length) {\n list.appendChild(el('div', { class: 'empty', style: { padding: '24px' }, text: 'No matches.' }));\n }\n for (const it of items) list.appendChild(it);\n const first = list.querySelector('.palette-item');\n if (first) first.classList.add('selected');\n },\n\n renderItem(name, kbd, run) {\n const kbdEls = (kbd || []).map((k) => el('span', { class: 'kbd', text: k }));\n const item = el('div', { class: 'palette-item' }, [\n el('div', { class: 'pi-name' }, [\n el(\n 'svg',\n {\n class: 'pi-icon',\n viewBox: '0 0 24 24',\n fill: 'none',\n stroke: 'currentColor',\n 'stroke-width': '2',\n },\n [el('path', { d: 'M9 18l6-6-6-6' })]\n ),\n el('span', { text: name }),\n ]),\n el('div', { class: 'pi-kbd' }, kbdEls),\n ]);\n item.addEventListener('click', () => {\n run();\n Modal.close('paletteBackdrop');\n });\n return item;\n },\n };\n\n // =====================================================================\n // Keyboard shortcuts\n // =====================================================================\n function bindKeys() {\n // 'g' chord state for G Q / G D / G S tab navigation.\n let chordAt = 0;\n document.addEventListener('keydown', (e) => {\n const cmd = e.metaKey || e.ctrlKey;\n // Cmd+K opens palette\n if (cmd && (e.key === 'k' || e.key === 'K')) {\n e.preventDefault();\n Palette.open();\n return;\n }\n // Cmd+Enter runs the builder query (works from inside inputs too — the\n // most common flow is typing a value and hitting Run).\n if (cmd && e.key === 'Enter') {\n e.preventDefault();\n if (State.currentTab !== 'builder') Router.selectTab('builder');\n BuilderPane.run();\n return;\n }\n // Cmd+S saves the current builder query (never the browser dialog).\n if (cmd && (e.key === 's' || e.key === 'S')) {\n e.preventDefault();\n if (State.currentTab !== 'builder') Router.selectTab('builder');\n BuilderPane.openSave();\n return;\n }\n if (e.key === 'Escape') {\n ['savePromptBackdrop', 'jsonModalBackdrop', 'paletteBackdrop', 'rowEditorBackdrop', 'pasteBackdrop', 'exportBackdrop'].forEach((id) =>\n Modal.close(id)\n );\n return;\n }\n if (isTypingInInput()) return;\n // G-chords: g then q/d/s within 800ms switches tabs.\n if (e.key === 'g' || e.key === 'G') {\n chordAt = Date.now();\n return;\n }\n if (chordAt && Date.now() - chordAt < 800) {\n const k = e.key.toLowerCase();\n const target = k === 'q' ? 'builder' : k === 'd' ? 'data' : k === 's' ? 'schema' : null;\n chordAt = 0;\n if (target) {\n e.preventDefault();\n Router.selectTab(target);\n return;\n }\n }\n if (e.key === 'R' && e.shiftKey) {\n boot(true);\n return;\n }\n if (e.key === 'r') {\n if (State.currentTab === 'data') DataPane.loadAndRender();\n return;\n }\n if (e.key === '/') {\n e.preventDefault();\n $('#sidebarSearch').focus();\n }\n if (e.key === '?') {\n Palette.open();\n }\n });\n }\n\n // =====================================================================\n // Demo mode switcher (only rendered in --demo mode)\n // =====================================================================\n const DemoSwitcher = {\n bind() {\n const piiBtn = $('#demoTogglePii');\n const writeBtn = $('#demoToggleWrite');\n if (piiBtn) piiBtn.addEventListener('click', () => DemoSwitcher.toggle({ showPii: !State.showPii }));\n if (writeBtn) writeBtn.addEventListener('click', () => DemoSwitcher.toggle({ writable: !State.writable }));\n },\n // Reflect the current server-side state on the two toggle pills.\n sync() {\n const piiBtn = $('#demoTogglePii');\n const writeBtn = $('#demoToggleWrite');\n if (piiBtn) {\n piiBtn.setAttribute('aria-pressed', State.showPii ? 'true' : 'false');\n $('#demoTogglePiiLabel').textContent = 'PII: ' + (State.showPii ? 'shown' : 'hidden');\n }\n if (writeBtn) {\n writeBtn.setAttribute('aria-pressed', State.writable ? 'true' : 'false');\n $('#demoToggleWriteLabel').textContent = 'Writes: ' + (State.writable ? 'on' : 'off');\n }\n },\n // POST the toggle, then re-boot so redaction / write affordances / banners\n // and the current view all reflect the new server-side state.\n async toggle(patch) {\n try {\n await Api.demoMode(patch);\n await boot(true);\n } catch (err) {\n toast('Failed to switch mode: ' + (err && err.message ? err.message : 'unknown'), 'err');\n }\n },\n };\n\n // =====================================================================\n // Init / boot\n // =====================================================================\n async function boot(reloadOnly) {\n try {\n const schema = await Api.schema();\n State.schema = schema;\n State.tables = schema.tables || [];\n State.tablesByName = new Map(State.tables.map((t) => [t.name, t]));\n State.enums = schema.enums || {};\n State.writable = schema.writable === true;\n State.showPii = schema.showPii === true;\n State.demo = schema.demo === true;\n State.connOk = true;\n // Persistent write-mode affordances (only when the server allows writes).\n $('#writeBanner').hidden = !State.writable;\n $('#dataInsert').hidden = !State.writable;\n $('#dataPaste').hidden = !State.writable;\n // Persistent unredacted-PII indicator (only under --show-pii).\n $('#piiBanner').hidden = !State.showPii;\n // Demo banner + live switcher (only in --demo mode). Its two toggle\n // pills reflect the current server state (which the write/PII banners\n // above also mirror).\n $('#demoBanner').hidden = !State.demo;\n if (State.demo) DemoSwitcher.sync();\n $('#connDot').classList.remove('err');\n $('#connDot').classList.add('ok');\n $('#connLabel').textContent = 'connected';\n const meta =\n (schema.schema || 'public') + ' · ' + State.tables.length + ' tables';\n $('#headerMeta').textContent = meta;\n $('#sidebarMeta').textContent = meta;\n\n if (reloadOnly) {\n // A live reload (demo mode toggle, Shift+R): keep the user's place —\n // current tab, table, composed builder query, filters, selection.\n // Re-point currentTable at the fresh metadata object and re-render;\n // re-fetch whatever the active tab is showing so redaction/write\n // affordances reflect the new server state.\n if (State.currentTable) {\n State.currentTable = State.tablesByName.get(State.currentTable.name) || null;\n }\n Sidebar.render();\n FilterBar.render();\n Router.selectTab(State.currentTab);\n if (State.currentTab === 'data' && State.currentTable) DataPane.loadAndRender();\n if (State.currentTab === 'builder' && State.builder.result) {\n // Results were fetched under the previous mode (e.g. different PII\n // redaction) — re-run so what's on screen is always truthful.\n BuilderPane.run();\n }\n SavedQueries.refresh();\n return;\n }\n\n // First boot: restore persisted layout + location.\n const savedW = Storage.get('sidebarW');\n if (savedW) document.documentElement.style.setProperty('--sidebar-w', savedW);\n const savedTab = Storage.get('tab', 'builder');\n const savedTable = Storage.get('currentTable');\n const initialTable = (savedTable && State.tablesByName.has(savedTable)\n ? savedTable\n : State.tables[0] && State.tables[0].name);\n if (initialTable) {\n State.currentTable = State.tablesByName.get(initialTable);\n }\n\n Sidebar.render();\n Router.selectTab(savedTab || 'builder');\n if (State.currentTable) {\n if (savedTab === 'data') DataPane.loadAndRender();\n if (savedTab === 'schema') SchemaPane.render();\n if (savedTab === 'builder' || !savedTab) BuilderPane.resetForTable(State.currentTable.name);\n }\n\n SavedQueries.refresh();\n } catch (err) {\n State.connOk = false;\n $('#connDot').classList.remove('ok');\n $('#connDot').classList.add('err');\n $('#connLabel').textContent = 'offline';\n $('#headerMeta').textContent = 'connection failed';\n $('#sidebarMeta').textContent = '—';\n // Surface the failure on EVERY pane (whichever tab is active) with a\n // retry affordance — a schema-load failure must never strand the UI on\n // an eternal \"Loading schema…\".\n const renderBootError = (host) => {\n host.innerHTML = '';\n host.appendChild(\n el('div', { class: 'error-box' }, [\n el('div', { class: 'error-box-title', text: 'Cannot reach /api/schema' }),\n document.createTextNode(err.message),\n el('div', { style: { marginTop: '10px' } }, [\n el('button', { class: 'btn btn-sm', text: 'Retry', onclick: () => boot(reloadOnly) }),\n ]),\n ])\n );\n };\n renderBootError($('#dataScroll'));\n renderBootError($('#builderLeft'));\n renderBootError($('#schemaScroll'));\n }\n }\n\n function init() {\n // Restore persisted preferences before the binds read them.\n State.data.limit = Storage.get('pageSize', 50);\n State.builder.showSql = Storage.get('showSql', false) === true;\n Sidebar.bind();\n Router.bind();\n DataPane.bind();\n BuilderPane.bind();\n Modal.bind();\n SavePrompt.bind();\n RowEditor.bind();\n Palette.bind();\n DemoSwitcher.bind();\n Exporter.bind();\n PasteRows.bind();\n bindKeys();\n boot();\n }\n\n document.addEventListener('DOMContentLoaded', init);\n </script>\n</body>\n</html>\n";
|