turbine-orm 0.64.1 → 0.65.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 +2 -0
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/client.js +1 -1
- package/dist/cjs/dialect.js +4 -1
- package/dist/cjs/errors.d.ts +2 -0
- package/dist/cjs/errors.js +29 -7
- package/dist/cjs/mysql.js +19 -0
- package/dist/cjs/observe.js +3 -1
- package/dist/cjs/powql.js +29 -0
- package/dist/cjs/query/aggregates.js +3 -3
- package/dist/cjs/query/batched-loader.d.ts +10 -0
- package/dist/cjs/query/batched-loader.js +91 -37
- package/dist/cjs/query/builder.js +15 -7
- package/dist/cjs/query/relations.js +28 -0
- package/dist/cjs/query/utils.d.ts +13 -0
- package/dist/cjs/query/utils.js +21 -0
- package/dist/cjs/sqlite.js +16 -0
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/client.js +1 -1
- package/dist/dialect.js +4 -1
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +29 -7
- package/dist/mysql.js +19 -0
- package/dist/observe.js +3 -1
- package/dist/powql.js +30 -1
- package/dist/query/aggregates.js +4 -4
- package/dist/query/batched-loader.d.ts +10 -0
- package/dist/query/batched-loader.js +91 -38
- package/dist/query/builder.js +16 -8
- package/dist/query/relations.js +29 -1
- package/dist/query/utils.d.ts +13 -0
- package/dist/query/utils.js +19 -0
- package/dist/sqlite.js +16 -0
- package/package.json +5 -3
|
@@ -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 /* 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 /* An author `display` rule (every bar and banner below is a flex row) beats\n the user-agent `[hidden] { display: none }`, so restate it here: the whole\n UI gates optional strips, the link bar, filter bar, selection bar, and\n the write / PII / demo banners, on the `hidden` property. */\n [hidden] {\n display: none !important;\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 /* EXACTLY three grid items, always: the banner stack, the header, the\n body. The banners are NOT direct grid children, and must never become\n them again. They used to be, against one `auto` track each\n (`auto auto auto var(--header-h) 1fr`), and a hidden banner is\n `display: none`, so it is not a grid item at all: the item count moved\n with the number of VISIBLE banners and every later child slid up a\n track. At exactly two banners that put `.main` in the\n `var(--header-h)` track, i.e. the entire app in a 48px-tall row, which\n rendered as a completely blank page (`--write --show-pii`, and `--demo`\n with PII toggled on). One `auto` track over a stack that is one item\n whether it holds three banners or none cannot drift with the count. */\n grid-template-rows: auto var(--header-h) 1fr;\n height: 100vh;\n width: 100vw;\n }\n /* The banner region. Collapses to zero height when every banner inside it\n is hidden (no padding, no border, no gap of its own), so the `auto` track\n above costs nothing in the default read-only Postgres session. */\n .banner-stack {\n display: flex;\n flex-direction: column;\n min-height: 0;\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 /* The query-key spelling of a column. Same weight as the column name it\n sits beside, dimmer, because it is the derived one. */\n .col-field {\n color: var(--text-dim);\n font-family: var(--mono);\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\n /* =====================================================================\n Foreign-key navigation bar (Data tab)\n ===================================================================== */\n .link-bar {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 8px;\n padding: 6px 20px;\n border-bottom: 1px solid var(--border);\n background: var(--accent-dim);\n font-family: var(--mono);\n font-size: 11.5px;\n color: var(--text-dim);\n flex-shrink: 0;\n }\n .link-bar .btn {\n padding: 3px 9px;\n font-size: 11px;\n }\n .link-bar-target {\n color: var(--accent);\n font-weight: 600;\n }\n .link-bar-sep {\n color: var(--text-muted);\n }\n .fk-link {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n color: var(--accent);\n border-bottom: 1px dashed var(--border-strong);\n padding: 0;\n background: none;\n font: inherit;\n cursor: pointer;\n transition: color var(--t), border-color var(--t);\n }\n .fk-link:hover {\n color: var(--accent-hover);\n border-bottom-color: var(--accent);\n }\n .fk-arrow {\n font-size: 9px;\n color: var(--text-muted);\n }\n .fk-link:hover .fk-arrow {\n color: var(--accent);\n }\n .th-note {\n margin-left: 6px;\n font-size: 10px;\n color: var(--text-muted);\n font-weight: 400;\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 <!-- Banner region: ONE grid row whatever it holds. Each banner is hidden\n independently, and a hidden element is not a grid item, so keeping them\n as direct children of `.app` made the row each later element landed in\n depend on how many banners happened to be visible. -->\n <div class=\"banner-stack\">\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-scan failure banner. Shown when the generated metadata exists but\n its PII tags could not be read, so the server is failing closed and\n redacting EVERY column. Without this, a screen full of redaction\n markers reads like a bug rather than a deliberate posture. -->\n <div class=\"write-banner\" id=\"piiScanBanner\" role=\"alert\" hidden>\n <span class=\"write-banner-dot\" aria-hidden=\"true\"></span>\n <strong>PII TAGS UNREADABLE</strong>\n <span id=\"piiScanBannerWhy\">every column is redacted</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 id=\"piiBannerWhy\">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 </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=\"link-bar\" id=\"dataLinkBar\" hidden></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 <!-- Seeded with the same empty state `renderResults()` writes\n when there is no result. The panel is only ever rendered by\n a run, so before the first one it was a blank rectangle\n with a \"Results\" header and no explanation. -->\n <div class=\"data-scroll\" id=\"builderResults\" style=\"flex:1\">\n <div class=\"empty\">Run the query to see rows here.</div>\n </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 // { path, reason, columns } when the server could not read PII tags and\n // is therefore redacting every column; null otherwise.\n piiScanFailed: null,\n // data pane\n data: {\n rows: [],\n columns: [],\n total: 0,\n // Table the rows above were actually fetched for; the Data tab refuses\n // to render rows belonging to a different table than the selected one.\n loadedTable: null,\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 // Foreign-key click-through: the views we navigated away from (each\n // entry restores a table + its filters/search/sort/page), plus a label\n // describing the reference that produced the current view.\n linkTrail: [],\n linkLabel: null,\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 // =====================================================================\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 // \"1 rows\" reads like a bug report; keep counts grammatical everywhere.\n\n function plural(n, word) {\n\n return formatNumber(n) + ' ' + word + (n === 1 ? '' : 's');\n\n }\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 // === BEGIN builder-args ===\n // Everything between these markers is PURE: no DOM, no State, no network.\n // src/test/studio-builder-roundtrip.test.ts extracts this exact region out\n // of the served HTML and runs it, so the save -> disk -> hydrate round trip\n // is tested as behaviour rather than as a regex over source text (which is\n // what let a broken round trip ship: the assertions checked that a line of\n // JavaScript existed, not that anything came back). Keep the region\n // dependency-free, and pass collaborators (findTable) in as arguments.\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 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 * The builder's args <-> Turbine args round trip.\n *\n * Two spellings are in play. The builder's own controls are labelled with\n * the database COLUMN name (that is what the Data and Schema tabs show), so\n * `State.builder.args` holds column names. A Turbine query addresses a\n * column by its camelCase FIELD name: `orderBy: { created_at: 'asc' }` is a\n * TS2353 against the generated types and a TURBINE_E003 at runtime.\n *\n * So the persisted / emitted form is FIELD-keyed, in both directions and at\n * every depth. It is the same object the Copy TS snippet shows and the same\n * object `/api/builder` receives, which means a saved query file is a\n * pasteable query, and one shape is stored rather than two.\n *\n * `hydrate` accepts BOTH spellings, because `.turbine/studio-queries.json`\n * files written before the emit side moved to field names are column-keyed\n * and must keep loading. A key that matches NEITHER is kept as written and\n * reported: it is never dropped, because a saved query that silently comes\n * back with fewer WHERE clauses than it was saved with returns more rows\n * than the user asked for, and nothing on screen says so.\n */\n const BuilderArgs = {\n /** FIELD name for a database column; the column name if it is unknown. */\n fieldName(tableMeta, columnName) {\n const col = ((tableMeta && tableMeta.columns) || []).find((c) => c.name === columnName);\n return (col && col.field) || columnName;\n },\n\n /**\n * The inverse: database COLUMN name for an args key, matching the field\n * spelling first (the format written today) and the column spelling\n * second (legacy files). `null` when the key names neither, which callers\n * must surface.\n */\n columnName(tableMeta, key) {\n const cols = (tableMeta && tableMeta.columns) || [];\n const byField = cols.find((c) => c.field === key);\n if (byField) return byField.name;\n const byName = cols.find((c) => c.name === key);\n return byName ? byName.name : 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 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 && tableMeta.columns) || []).find((cc) => cc.name === c.column);\n // An unknown column is emitted AS WRITTEN rather than skipped. It used\n // to be skipped, so a clause could vanish between the pane and the\n // query. The bad key now shows up in the preview and `validate()`\n // refuses to Run or Save while it is there; the server validates\n // identifiers itself, so an unknown key is a 400, never a silent\n // unfiltered read.\n const key = col ? col.field || c.column : c.column;\n if (isNullOp(c.op)) {\n clauses.push({ [key]: c.op === 'isNull' ? null : { not: null } });\n continue;\n }\n // An empty value is the one skip left, and `validate()` already\n // reports it (\"enter a value, or remove the clause\") and blocks Run.\n const parsed = BuilderArgs.coerceValue(c.value, col ? col.tsType : 'string', 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({ [key]: inner });\n }\n if (!clauses.length) return null;\n if (clauses.length === 1 && localArgs.combinator === 'AND') return clauses[0];\n if (localArgs.combinator === 'AND') {\n // Two clauses on the SAME column used to be merged with\n // Object.assign, where the later key overwrites the earlier one: a\n // range like `age gte 18` AND `age lte 65` silently became `age lte\n // 65` alone, so the pane showed two filters and the query applied\n // one. Every clause is its own object under an explicit `AND` when\n // any column repeats, which is the shape hydrate() already reads\n // back, so a saved query still round-trips.\n const keys = clauses.map((c) => Object.keys(c)[0]);\n if (new Set(keys).size !== keys.length) return { AND: clauses };\n return Object.assign({}, ...clauses);\n }\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 buildFindManyArgs(localArgs, tableMeta, findTable) {\n const out = {};\n const whereObj = BuilderArgs.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 && tableMeta.relations) || []).find((r) => r.name === w.relation);\n if (!rel) continue;\n const childMeta = findTable ? findTable(rel.to) : null;\n const childArgs = childMeta ? BuilderArgs.buildFindManyArgs(w.args, childMeta, findTable) : 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 = { [BuilderArgs.fieldName(tableMeta, 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) => [BuilderArgs.fieldName(tableMeta, c), true]));\n }\n if (localArgs.omit && localArgs.omit.length) {\n out.omit = Object.fromEntries(localArgs.omit.map((c) => [BuilderArgs.fieldName(tableMeta, c), true]));\n }\n return out;\n },\n\n /**\n * Inverse of buildFindManyArgs. `issues` (optional array) collects a\n * human-readable line for everything that could not be resolved, so the\n * loader can say what happened instead of quietly loading less.\n */\n hydrate(argsObj, tableMeta, findTable, issues) {\n const out = emptyBuilderArgs();\n const note = (msg) => {\n if (issues) issues.push(msg);\n };\n if (!tableMeta) return out;\n const resolve = (key, where) => {\n const col = BuilderArgs.columnName(tableMeta, key);\n if (col) return col;\n note(where + ': no column named \"' + key + '\" on ' + tableMeta.name);\n // Kept, not dropped: validate() turns it into a visible refusal.\n return key;\n };\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: resolve(k, 'orderBy'), dir: argsObj.orderBy[k] };\n }\n if (argsObj.where) {\n const w = argsObj.where;\n const collectInto = (obj) => {\n for (const [key, val] of Object.entries(obj)) {\n if (key === 'AND' || key === 'OR' || key === 'NOT') continue;\n const column = resolve(key, 'where');\n if (val === null) {\n // { field: null } is IS NULL\n out.where.push({ column, 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 // { field: { not: null } } is IS NOT NULL\n out.where.push({ column, op: 'isNotNull', value: '', insensitive: false });\n } else {\n out.where.push({\n column,\n op,\n value: Array.isArray(val[op]) ? val[op].join(',') : val[op],\n insensitive: val.mode === 'insensitive',\n });\n }\n } else {\n out.where.push({ column, 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).map((k) => resolve(k, 'select'));\n if (argsObj.omit) out.omit = Object.keys(argsObj.omit).map((k) => resolve(k, '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) {\n note('with: no relation named \"' + relName + '\" on ' + tableMeta.name);\n continue;\n }\n const childMeta = findTable ? findTable(rel.to) : null;\n if (!childMeta) {\n note('with.' + relName + ': table \"' + rel.to + '\" is not in this schema');\n continue;\n }\n const childIssues = [];\n const childArgs =\n v === true ? emptyBuilderArgs() : BuilderArgs.hydrate(v, childMeta, findTable, childIssues);\n for (const msg of childIssues) note(relName + '.' + msg);\n out.with.push({ relation: relName, expanded: true, args: childArgs });\n }\n }\n return out;\n },\n\n /**\n * Every column key in the pane's own args that names no column, at any\n * depth. Only a hydrated saved query can produce one (the controls\n * themselves are built from metadata), and that is exactly the case that\n * has to be loud rather than quietly re-emitted.\n */\n unresolvedColumns(localArgs, tableMeta, findTable, path) {\n const at = path || [];\n const out = [];\n if (!tableMeta || !localArgs) return out;\n const unknown = (key) => key && !BuilderArgs.columnName(tableMeta, key);\n (localArgs.where || []).forEach((c, i) => {\n if (unknown(c.column)) out.push({ kind: 'where', key: c.column, index: i, path: at });\n });\n if (localArgs.orderBy && unknown(localArgs.orderBy.column)) {\n out.push({ kind: 'orderBy', key: localArgs.orderBy.column, path: at });\n }\n for (const kind of ['select', 'omit']) {\n for (const key of localArgs[kind] || []) {\n if (unknown(key)) out.push({ kind, key, path: at });\n }\n }\n for (const w of localArgs.with || []) {\n const rel = ((tableMeta.relations) || []).find((r) => r.name === w.relation);\n const childMeta = rel && findTable ? findTable(rel.to) : null;\n if (childMeta) {\n for (const e of BuilderArgs.unresolvedColumns(w.args, childMeta, findTable, at.concat(w.relation))) {\n out.push(e);\n }\n }\n }\n return out;\n },\n\n /** One wording for an unresolved key, shared by the toast and validate(). */\n describeUnresolved(entry) {\n const at = entry.path && entry.path.length ? entry.path.join('.') + ' ' : '';\n const what = entry.kind === 'where' ? 'where clause ' + (entry.index + 1) : entry.kind;\n return at + what + ': unknown column \"' + entry.key + '\" (remove it, or pick a column).';\n },\n };\n // === END builder-args ===\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 // An ESTIMATE outside demo mode: it is pg_class.reltuples,\n // which is whatever the last ANALYZE / autovacuum left behind\n // and is -1 (served as 0) on a table that has never been\n // analyzed. Unlabelled it read as a count and contradicted the\n // Data tab, which runs a real COUNT(*). Demo counts ARE exact\n // (COUNT(*) over the in-memory store), so no tilde there.\n el('span', {\n class: 'sidebar-item-count',\n text: (State.demo ? '' : '~') + formatCount(t.estimatedRows),\n title: State.demo\n ? 'Exact row count'\n : 'Planner estimate (pg_class.reltuples), stale or 0 until the table is ANALYZEd. The Data tab counts for real.',\n }),\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') {\n // Re-fetch when the loaded rows belong to a different table (the user\n // picked one from the sidebar while on another tab). Rendering stale\n // rows under the new table's header is worse than a brief spinner.\n const stale = !State.currentTable || State.data.loadedTable !== State.currentTable.name;\n if (stale) DataPane.loadAndRender();\n else DataPane.render();\n }\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 // Picking a table from the sidebar is a fresh start: drop any\n // foreign-key navigation trail so the back affordance never lies.\n State.data.linkTrail = [];\n State.data.linkLabel = null;\n FilterBar.render();\n Nav.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 // Foreign-key navigation (Data tab)\n //\n // The server derives every link from relation metadata and omits any whose\n // columns are currently PII-redacted, so the UI only has to follow them.\n // A jump is just the normal table view with one equals filter applied, so\n // it reuses the Data tab's own loading, paging, and filter-bar plumbing -\n // and the filter is a real filter row the user can delete.\n // =====================================================================\n const Nav = {\n // Forward links (this row references another table), keyed by column.\n fkFor(column) {\n const t = State.currentTable;\n const links = (t && t.foreignKeys) || [];\n return links.find((l) => l.column === column) || null;\n },\n\n // Reverse links (other tables reference this one).\n childLinks() {\n const t = State.currentTable;\n return (t && t.referencedBy) || [];\n },\n\n // The single equals filter pinning the current view to one value, or null.\n pinnedFilter() {\n const active = FilterBar.activeFilters();\n if (active.length !== 1) return null;\n return active[0].op === 'equals' ? active[0] : null;\n },\n\n // Open `table` filtered to `column = value`, remembering where we came\n // from so Back can restore it exactly.\n open(tableName, column, value, label, push) {\n const target = State.tablesByName.get(tableName);\n if (!target) {\n toast('Unknown table: ' + tableName, 'err');\n return;\n }\n if (push && State.currentTable) {\n State.data.linkTrail.push({\n table: State.currentTable.name,\n filters: State.data.filters.map((f) => ({ ...f })),\n search: State.data.search,\n orderBy: State.data.orderBy,\n dir: State.data.dir,\n offset: State.data.offset,\n linkLabel: State.data.linkLabel,\n });\n }\n State.currentTable = target;\n State.data.offset = 0;\n State.data.search = '';\n $('#dataSearch').value = '';\n State.data.orderBy =\n (target.primaryKey && target.primaryKey[0]) || (target.columns[0] && target.columns[0].name);\n State.data.dir = 'asc';\n State.data.filters = [{ column, op: 'equals', value: String(value) }];\n State.data.selected.clear();\n State.data.linkLabel = label;\n Storage.set('currentTable', target.name);\n Sidebar.render();\n FilterBar.render();\n if (State.currentTab !== 'data') Router.selectTab('data');\n DataPane.loadAndRender();\n },\n\n // Follow the reference in one cell to the row it points at.\n follow(link, value) {\n if (value === null || value === undefined) return;\n const from = State.currentTable ? State.currentTable.name : '?';\n Nav.open(link.targetTable, link.targetColumn, value, from + '.' + link.relation, true);\n },\n\n // Jump the other way: the child rows referencing the pinned row.\n followChild(link, value) {\n const from = State.currentTable ? State.currentTable.name : '?';\n Nav.open(link.targetTable, link.targetColumn, value, from + '.' + link.relation, true);\n },\n\n back() {\n const prev = State.data.linkTrail.pop();\n if (!prev) return;\n const target = State.tablesByName.get(prev.table);\n if (!target) return;\n State.currentTable = target;\n State.data.filters = prev.filters.map((f) => ({ ...f }));\n State.data.search = prev.search || '';\n $('#dataSearch').value = State.data.search;\n State.data.orderBy = prev.orderBy;\n State.data.dir = prev.dir;\n State.data.offset = prev.offset || 0;\n State.data.linkLabel = prev.linkLabel || null;\n State.data.selected.clear();\n Storage.set('currentTable', target.name);\n Sidebar.render();\n FilterBar.render();\n DataPane.loadAndRender();\n },\n\n clearFilter() {\n State.data.filters = [];\n State.data.linkLabel = null;\n State.data.offset = 0;\n FilterBar.render();\n DataPane.loadAndRender();\n },\n\n // The bar above the filter bar: where we came from, what is pinned, and\n // the reverse jumps available from a pinned row.\n render() {\n const bar = $('#dataLinkBar');\n if (!bar) return;\n bar.innerHTML = '';\n const t = State.currentTable;\n const pinned = t ? Nav.pinnedFilter() : null;\n const trail = State.data.linkTrail;\n const children = pinned ? Nav.childLinks().filter((l) => l.column === pinned.column) : [];\n if (!t || (!trail.length && !State.data.linkLabel && !children.length)) {\n bar.hidden = true;\n return;\n }\n bar.hidden = false;\n\n if (trail.length) {\n const prev = trail[trail.length - 1];\n bar.appendChild(\n el('button', {\n class: 'btn btn-sm',\n text: '← Back to ' + prev.table,\n onclick: () => Nav.back(),\n })\n );\n }\n if (State.data.linkLabel) {\n bar.appendChild(el('span', { class: 'link-bar-sep', text: 'via' }));\n bar.appendChild(el('span', { class: 'link-bar-target', text: State.data.linkLabel }));\n }\n if (pinned) {\n bar.appendChild(el('span', { class: 'link-bar-sep', text: '·' }));\n bar.appendChild(\n el('span', { text: t.name + '.' + pinned.column + ' = ' + String(pinned.value) })\n );\n bar.appendChild(\n el('button', {\n class: 'btn btn-ghost btn-sm',\n text: 'Clear filter',\n onclick: () => Nav.clearFilter(),\n })\n );\n }\n if (children.length && pinned) {\n bar.appendChild(el('span', { class: 'link-bar-sep', text: 'referenced by' }));\n for (const link of children) {\n bar.appendChild(\n el('button', {\n class: 'btn btn-sm',\n text: link.targetTable + '.' + link.targetColumn + ' →',\n onclick: () => Nav.followChild(link, pinned.value),\n })\n );\n }\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 $('#dataLinkBar').hidden = true;\n $('#dataRange').textContent = '-';\n $('#pageIndicator').textContent = '-';\n return;\n }\n $('#dataTitle').textContent = t.name;\n $('#dataTotal').textContent = State.data.loading\n ? 'loading…'\n : formatNumber(State.data.total) + (State.data.total === 1 ? ' row' : ' rows');\n DataPane.renderSelectionBar();\n Nav.render();\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 // Foreign-key cells become links to the referenced row; a null\n // reference (or a column with no relation) renders exactly as before.\n cellLink: (col, val) => {\n const link = Nav.fkFor(col);\n if (!link || val === null || val === undefined) return null;\n return {\n title: 'Open ' + link.targetTable + ' where ' + link.targetColumn + ' = ' + String(val),\n onClick: () => Nav.follow(link, val),\n };\n },\n headerNote: (col) => {\n const link = Nav.fkFor(col);\n return link ? '→ ' + link.targetTable + '.' + link.targetColumn : null;\n },\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 ' + plural(res.rowCount || targets.length, 'row'), '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 // Which table the rows in State.data actually came from. The Data tab\n // renders whatever is in State.data, so without this a table picked\n // from another tab would render the PREVIOUS table's rows under the\n // new table's name until the next refresh.\n State.data.loadedTable = t.name;\n } catch (err) {\n State.data.error = err.message;\n State.data.rows = [];\n State.data.total = 0;\n State.data.loadedTable = t.name;\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 ' + plural(res.rowCount || rows.length, 'row'), '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 const note = opts.headerNote ? opts.headerNote(c) : null;\n if (note) th.appendChild(el('span', { class: 'th-note', text: note }));\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 const link = opts.cellLink ? opts.cellLink(c, val, row) : null;\n tr.appendChild(link ? Table.renderLinkCell(val, link) : 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 // A foreign-key cell: the value stays exactly as rendered elsewhere (raw\n // text, copyable via double-click) but is wrapped in a button that opens\n // the referenced row.\n renderLinkCell(val, link) {\n const s = String(val);\n const td = el('td', { class: 'cell-fk' });\n td.dataset.raw = s;\n td.appendChild(\n el(\n 'button',\n {\n class: 'fk-link',\n title: link.title,\n onclick: (e) => {\n e.stopPropagation();\n link.onClick();\n },\n },\n [s, el('span', { class: 'fk-arrow', text: '↗' })]\n )\n );\n return td;\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 // \"Field\" is the camelCase name a QUERY uses (`where: { createdAt: ... }`).\n // The schema view showed the TS type but never the TS name, so the only\n // spelling on screen was the database column one, which no query key\n // accepts.\n colHead.appendChild(\n el('tr', {}, ['Column', 'Field', '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(\n el('td', {}, [\n el('span', {\n class: 'col-field',\n text: c.field || c.name,\n title: 'The name to use in a query (where / orderBy / select)',\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 // Clearing `result` alone left the PREVIOUS table's rows on screen (only\n // a run repaints the grid), so the pane showed a query for one table\n // above the rows of another.\n BuilderPane.renderResults();\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\n // Assembly lives in `BuilderArgs` (see the builder-args region above), so\n // the emit side and the saved-query hydrate side are one implementation\n // that cannot drift apart. These stay as the pane's call sites.\n fieldName(tableMeta, columnName) {\n return BuilderArgs.fieldName(tableMeta, columnName);\n },\n\n buildFindManyArgs(localArgs, tableMeta) {\n return BuilderArgs.buildFindManyArgs(localArgs, tableMeta, BuilderPane.findTable);\n },\n\n assembleWhere(localArgs, tableMeta) {\n return BuilderArgs.assembleWhere(localArgs, tableMeta);\n },\n\n coerceValue(val, tsType, op) {\n return BuilderArgs.coerceValue(val, tsType, op);\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 // Keys that name no column. The controls cannot produce one, a saved\n // query can (a renamed or dropped column, or a file saved against a\n // different schema), and the old behaviour was to drop the clause on\n // the way to the server and run the query unfiltered. Run and Save are\n // refused instead, at every depth, naming the key.\n const tableMetaForValidation = BuilderPane.findTable(State.builder.table);\n BuilderArgs.unresolvedColumns(\n State.builder.args,\n tableMetaForValidation,\n BuilderPane.findTable,\n ).forEach((entry, i) => {\n errs['unknown' + i] = BuilderArgs.describeUnresolved(entry);\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 the query to see rows here.</div>';\n return;\n }\n header.textContent =\n 'Results · ' + formatNumber(r.rowCount) + (r.rowCount === 1 ? ' row · ' : ' 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 // The pane's internal shape (column names, arrays) differs from the\n // flat field-keyed args object on disk, so loading is a real inverse\n // (BuilderArgs.hydrate) and anything it cannot resolve is REPORTED.\n // A saved query that quietly loads without its WHERE clauses returns\n // more rows than it did when it was saved.\n State.builder.table = q.table;\n const issues = [];\n State.builder.args = SavedQueries.hydrateBuilderArgs(q.args || {}, q.table, issues);\n BuilderPane.render();\n if (issues.length) {\n toast(\n 'Loaded \"' + q.name + '\" with ' + issues.length + ' unresolved item(s): ' + issues.join(' · '),\n 'err'\n );\n } else {\n toast('Loaded saved builder: ' + q.name, 'ok');\n }\n }\n },\n\n hydrateBuilderArgs(argsObj, tableName, issues) {\n const tableMeta = State.tablesByName.get(tableName);\n if (!tableMeta) {\n if (issues) issues.push('table \"' + tableName + '\" is not in this schema');\n return emptyBuilderArgs();\n }\n return BuilderArgs.hydrate(argsObj, tableMeta, BuilderPane.findTable, issues);\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 // Fail-closed indicator: the server found a generated metadata file it\n // could not read tags out of, so it tagged every column instead of\n // trusting an empty result.\n State.piiScanFailed = schema.piiScanFailed || null;\n $('#piiScanBanner').hidden = !State.piiScanFailed;\n if (State.piiScanFailed) {\n $('#piiScanBannerWhy').textContent =\n 'could not read PII tags from ' + State.piiScanFailed.path + ' (' + State.piiScanFailed.reason + '), ' +\n 'so all ' + State.piiScanFailed.columns + ' columns are redacted, sort / search / filter are refused, ' +\n 'and the Query tab is disabled. Re-run `turbine generate`, or restart with --show-pii.';\n }\n // Persistent unredacted-PII indicator. In demo mode the in-UI pill can\n // flip it, so the reason line must not blame a flag that was not passed.\n $('#piiBanner').hidden = !State.showPii;\n $('#piiBannerWhy').textContent = State.demo\n ? 'PII-tagged column values are displayed unredacted (switched on in this demo session)'\n : 'PII-tagged column values are displayed unredacted (--show-pii)';\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";
|
|
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 /* An author `display` rule (every bar and banner below is a flex row) beats\n the user-agent `[hidden] { display: none }`, so restate it here: the whole\n UI gates optional strips, the link bar, filter bar, selection bar, and\n the write / PII / demo banners, on the `hidden` property. */\n [hidden] {\n display: none !important;\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 /* EXACTLY three grid items, always: the banner stack, the header, the\n body. The banners are NOT direct grid children, and must never become\n them again. They used to be, against one `auto` track each\n (`auto auto auto var(--header-h) 1fr`), and a hidden banner is\n `display: none`, so it is not a grid item at all: the item count moved\n with the number of VISIBLE banners and every later child slid up a\n track. At exactly two banners that put `.main` in the\n `var(--header-h)` track, i.e. the entire app in a 48px-tall row, which\n rendered as a completely blank page (`--write --show-pii`, and `--demo`\n with PII toggled on). One `auto` track over a stack that is one item\n whether it holds three banners or none cannot drift with the count. */\n grid-template-rows: auto var(--header-h) 1fr;\n height: 100vh;\n width: 100vw;\n }\n /* The banner region. Collapses to zero height when every banner inside it\n is hidden (no padding, no border, no gap of its own), so the `auto` track\n above costs nothing in the default read-only Postgres session. */\n .banner-stack {\n display: flex;\n flex-direction: column;\n min-height: 0;\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 /* The query-key spelling of a column. Same weight as the column name it\n sits beside, dimmer, because it is the derived one. */\n .col-field {\n color: var(--text-dim);\n font-family: var(--mono);\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\n /* =====================================================================\n Foreign-key navigation bar (Data tab)\n ===================================================================== */\n .link-bar {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 8px;\n padding: 6px 20px;\n border-bottom: 1px solid var(--border);\n background: var(--accent-dim);\n font-family: var(--mono);\n font-size: 11.5px;\n color: var(--text-dim);\n flex-shrink: 0;\n }\n .link-bar .btn {\n padding: 3px 9px;\n font-size: 11px;\n }\n .link-bar-target {\n color: var(--accent);\n font-weight: 600;\n }\n .link-bar-sep {\n color: var(--text-muted);\n }\n .fk-link {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n color: var(--accent);\n border-bottom: 1px dashed var(--border-strong);\n padding: 0;\n background: none;\n font: inherit;\n cursor: pointer;\n transition: color var(--t), border-color var(--t);\n }\n .fk-link:hover {\n color: var(--accent-hover);\n border-bottom-color: var(--accent);\n }\n .fk-arrow {\n font-size: 9px;\n color: var(--text-muted);\n }\n .fk-link:hover .fk-arrow {\n color: var(--accent);\n }\n .th-note {\n margin-left: 6px;\n font-size: 10px;\n color: var(--text-muted);\n font-weight: 400;\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 <!-- Banner region: ONE grid row whatever it holds. Each banner is hidden\n independently, and a hidden element is not a grid item, so keeping them\n as direct children of `.app` made the row each later element landed in\n depend on how many banners happened to be visible. -->\n <div class=\"banner-stack\">\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-scan failure banner. Shown when the generated metadata exists but\n its PII tags could not be read, so the server is failing closed and\n redacting EVERY column. Without this, a screen full of redaction\n markers reads like a bug rather than a deliberate posture. -->\n <div class=\"write-banner\" id=\"piiScanBanner\" role=\"alert\" hidden>\n <span class=\"write-banner-dot\" aria-hidden=\"true\"></span>\n <strong>PII TAGS UNREADABLE</strong>\n <span id=\"piiScanBannerWhy\">every column is redacted</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 id=\"piiBannerWhy\">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 </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=\"link-bar\" id=\"dataLinkBar\" hidden></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 <!-- Seeded with the same empty state `renderResults()` writes\n when there is no result. The panel is only ever rendered by\n a run, so before the first one it was a blank rectangle\n with a \"Results\" header and no explanation. -->\n <div class=\"data-scroll\" id=\"builderResults\" style=\"flex:1\">\n <div class=\"empty\">Run the query to see rows here.</div>\n </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 // { path, reason, columns } when the server could not read PII tags and\n // is therefore redacting every column; null otherwise.\n piiScanFailed: null,\n // data pane\n data: {\n rows: [],\n columns: [],\n total: 0,\n // Table the rows above were actually fetched for; the Data tab refuses\n // to render rows belonging to a different table than the selected one.\n loadedTable: null,\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 // Foreign-key click-through: the views we navigated away from (each\n // entry restores a table + its filters/search/sort/page), plus a label\n // describing the reference that produced the current view.\n linkTrail: [],\n linkLabel: null,\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 // =====================================================================\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 // \"1 rows\" reads like a bug report; keep counts grammatical everywhere.\n\n function plural(n, word) {\n\n return formatNumber(n) + ' ' + word + (n === 1 ? '' : 's');\n\n }\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 // === BEGIN builder-args ===\n // Everything between these markers is PURE: no DOM, no State, no network.\n // src/test/studio-builder-roundtrip.test.ts extracts this exact region out\n // of the served HTML and runs it, so the save -> disk -> hydrate round trip\n // is tested as behaviour rather than as a regex over source text (which is\n // what let a broken round trip ship: the assertions checked that a line of\n // JavaScript existed, not that anything came back). Keep the region\n // dependency-free, and pass collaborators (findTable) in as arguments.\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 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 * The builder's args <-> Turbine args round trip.\n *\n * Two spellings are in play. The builder's own controls are labelled with\n * the database COLUMN name (that is what the Data and Schema tabs show), so\n * `State.builder.args` holds column names. A Turbine query addresses a\n * column by its camelCase FIELD name: `orderBy: { created_at: 'asc' }` is a\n * TS2353 against the generated types and a TURBINE_E003 at runtime.\n *\n * So the persisted / emitted form is FIELD-keyed, in both directions and at\n * every depth. It is the same object the Copy TS snippet shows and the same\n * object `/api/builder` receives, which means a saved query file is a\n * pasteable query, and one shape is stored rather than two.\n *\n * `hydrate` accepts BOTH spellings, because `.turbine/studio-queries.json`\n * files written before the emit side moved to field names are column-keyed\n * and must keep loading. A key that matches NEITHER is kept as written and\n * reported: it is never dropped, because a saved query that silently comes\n * back with fewer WHERE clauses than it was saved with returns more rows\n * than the user asked for, and nothing on screen says so.\n */\n const BuilderArgs = {\n /** FIELD name for a database column; the column name if it is unknown. */\n fieldName(tableMeta, columnName) {\n const col = ((tableMeta && tableMeta.columns) || []).find((c) => c.name === columnName);\n return (col && col.field) || columnName;\n },\n\n /**\n * The inverse: database COLUMN name for an args key, matching the field\n * spelling first (the format written today) and the column spelling\n * second (legacy files). `null` when the key names neither, which callers\n * must surface.\n */\n columnName(tableMeta, key) {\n const cols = (tableMeta && tableMeta.columns) || [];\n const byField = cols.find((c) => c.field === key);\n if (byField) return byField.name;\n const byName = cols.find((c) => c.name === key);\n return byName ? byName.name : 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 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 && tableMeta.columns) || []).find((cc) => cc.name === c.column);\n // An unknown column is emitted AS WRITTEN rather than skipped. It used\n // to be skipped, so a clause could vanish between the pane and the\n // query. The bad key now shows up in the preview and `validate()`\n // refuses to Run or Save while it is there; the server validates\n // identifiers itself, so an unknown key is a 400, never a silent\n // unfiltered read.\n const key = col ? col.field || c.column : c.column;\n if (isNullOp(c.op)) {\n clauses.push({ [key]: c.op === 'isNull' ? null : { not: null } });\n continue;\n }\n // An empty value is the one skip left, and `validate()` already\n // reports it (\"enter a value, or remove the clause\") and blocks Run.\n const parsed = BuilderArgs.coerceValue(c.value, col ? col.tsType : 'string', 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({ [key]: inner });\n }\n if (!clauses.length) return null;\n if (clauses.length === 1 && localArgs.combinator === 'AND') return clauses[0];\n if (localArgs.combinator === 'AND') {\n // Two clauses on the SAME column used to be merged with\n // Object.assign, where the later key overwrites the earlier one: a\n // range like `age gte 18` AND `age lte 65` silently became `age lte\n // 65` alone, so the pane showed two filters and the query applied\n // one. Every clause is its own object under an explicit `AND` when\n // any column repeats, which is the shape hydrate() already reads\n // back, so a saved query still round-trips.\n const keys = clauses.map((c) => Object.keys(c)[0]);\n if (new Set(keys).size !== keys.length) return { AND: clauses };\n return Object.assign({}, ...clauses);\n }\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 buildFindManyArgs(localArgs, tableMeta, findTable) {\n const out = {};\n const whereObj = BuilderArgs.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 && tableMeta.relations) || []).find((r) => r.name === w.relation);\n if (!rel) continue;\n const childMeta = findTable ? findTable(rel.to) : null;\n const childArgs = childMeta ? BuilderArgs.buildFindManyArgs(w.args, childMeta, findTable) : 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 = { [BuilderArgs.fieldName(tableMeta, 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) => [BuilderArgs.fieldName(tableMeta, c), true]));\n }\n // select + omit together is refused by the ORM (mutually exclusive),\n // and the chips enforce that live; this guard covers a SAVED query\n // from before the rule, where both lists survived on disk. select wins\n // because it is the more explicit of the two.\n if (localArgs.omit && localArgs.omit.length && !(localArgs.select && localArgs.select.length)) {\n out.omit = Object.fromEntries(localArgs.omit.map((c) => [BuilderArgs.fieldName(tableMeta, c), true]));\n }\n return out;\n },\n\n /**\n * Inverse of buildFindManyArgs. `issues` (optional array) collects a\n * human-readable line for everything that could not be resolved, so the\n * loader can say what happened instead of quietly loading less.\n */\n hydrate(argsObj, tableMeta, findTable, issues) {\n const out = emptyBuilderArgs();\n const note = (msg) => {\n if (issues) issues.push(msg);\n };\n if (!tableMeta) return out;\n const resolve = (key, where) => {\n const col = BuilderArgs.columnName(tableMeta, key);\n if (col) return col;\n note(where + ': no column named \"' + key + '\" on ' + tableMeta.name);\n // Kept, not dropped: validate() turns it into a visible refusal.\n return key;\n };\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: resolve(k, 'orderBy'), dir: argsObj.orderBy[k] };\n }\n if (argsObj.where) {\n const w = argsObj.where;\n const collectInto = (obj) => {\n for (const [key, val] of Object.entries(obj)) {\n if (key === 'AND' || key === 'OR' || key === 'NOT') continue;\n const column = resolve(key, 'where');\n if (val === null) {\n // { field: null } is IS NULL\n out.where.push({ column, 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 // { field: { not: null } } is IS NOT NULL\n out.where.push({ column, op: 'isNotNull', value: '', insensitive: false });\n } else {\n out.where.push({\n column,\n op,\n value: Array.isArray(val[op]) ? val[op].join(',') : val[op],\n insensitive: val.mode === 'insensitive',\n });\n }\n } else {\n out.where.push({ column, 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).map((k) => resolve(k, 'select'));\n if (argsObj.omit) out.omit = Object.keys(argsObj.omit).map((k) => resolve(k, '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) {\n note('with: no relation named \"' + relName + '\" on ' + tableMeta.name);\n continue;\n }\n const childMeta = findTable ? findTable(rel.to) : null;\n if (!childMeta) {\n note('with.' + relName + ': table \"' + rel.to + '\" is not in this schema');\n continue;\n }\n const childIssues = [];\n const childArgs =\n v === true ? emptyBuilderArgs() : BuilderArgs.hydrate(v, childMeta, findTable, childIssues);\n for (const msg of childIssues) note(relName + '.' + msg);\n out.with.push({ relation: relName, expanded: true, args: childArgs });\n }\n }\n return out;\n },\n\n /**\n * Every column key in the pane's own args that names no column, at any\n * depth. Only a hydrated saved query can produce one (the controls\n * themselves are built from metadata), and that is exactly the case that\n * has to be loud rather than quietly re-emitted.\n */\n unresolvedColumns(localArgs, tableMeta, findTable, path) {\n const at = path || [];\n const out = [];\n if (!tableMeta || !localArgs) return out;\n const unknown = (key) => key && !BuilderArgs.columnName(tableMeta, key);\n (localArgs.where || []).forEach((c, i) => {\n if (unknown(c.column)) out.push({ kind: 'where', key: c.column, index: i, path: at });\n });\n if (localArgs.orderBy && unknown(localArgs.orderBy.column)) {\n out.push({ kind: 'orderBy', key: localArgs.orderBy.column, path: at });\n }\n for (const kind of ['select', 'omit']) {\n for (const key of localArgs[kind] || []) {\n if (unknown(key)) out.push({ kind, key, path: at });\n }\n }\n for (const w of localArgs.with || []) {\n const rel = ((tableMeta.relations) || []).find((r) => r.name === w.relation);\n const childMeta = rel && findTable ? findTable(rel.to) : null;\n if (childMeta) {\n for (const e of BuilderArgs.unresolvedColumns(w.args, childMeta, findTable, at.concat(w.relation))) {\n out.push(e);\n }\n }\n }\n return out;\n },\n\n /** One wording for an unresolved key, shared by the toast and validate(). */\n describeUnresolved(entry) {\n const at = entry.path && entry.path.length ? entry.path.join('.') + ' ' : '';\n const what = entry.kind === 'where' ? 'where clause ' + (entry.index + 1) : entry.kind;\n return at + what + ': unknown column \"' + entry.key + '\" (remove it, or pick a column).';\n },\n };\n // === END builder-args ===\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 // An ESTIMATE outside demo mode: it is pg_class.reltuples,\n // which is whatever the last ANALYZE / autovacuum left behind\n // and is -1 (served as 0) on a table that has never been\n // analyzed. Unlabelled it read as a count and contradicted the\n // Data tab, which runs a real COUNT(*). Demo counts ARE exact\n // (COUNT(*) over the in-memory store), so no tilde there.\n el('span', {\n class: 'sidebar-item-count',\n text: (State.demo ? '' : '~') + formatCount(t.estimatedRows),\n title: State.demo\n ? 'Exact row count'\n : 'Planner estimate (pg_class.reltuples), stale or 0 until the table is ANALYZEd. The Data tab counts for real.',\n }),\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') {\n // Re-fetch when the loaded rows belong to a different table (the user\n // picked one from the sidebar while on another tab). Rendering stale\n // rows under the new table's header is worse than a brief spinner.\n const stale = !State.currentTable || State.data.loadedTable !== State.currentTable.name;\n if (stale) DataPane.loadAndRender();\n else DataPane.render();\n }\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 // Picking a table from the sidebar is a fresh start: drop any\n // foreign-key navigation trail so the back affordance never lies.\n State.data.linkTrail = [];\n State.data.linkLabel = null;\n FilterBar.render();\n Nav.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 // Foreign-key navigation (Data tab)\n //\n // The server derives every link from relation metadata and omits any whose\n // columns are currently PII-redacted, so the UI only has to follow them.\n // A jump is just the normal table view with one equals filter applied, so\n // it reuses the Data tab's own loading, paging, and filter-bar plumbing -\n // and the filter is a real filter row the user can delete.\n // =====================================================================\n const Nav = {\n // Forward links (this row references another table), keyed by column.\n fkFor(column) {\n const t = State.currentTable;\n const links = (t && t.foreignKeys) || [];\n return links.find((l) => l.column === column) || null;\n },\n\n // Reverse links (other tables reference this one).\n childLinks() {\n const t = State.currentTable;\n return (t && t.referencedBy) || [];\n },\n\n // The single equals filter pinning the current view to one value, or null.\n pinnedFilter() {\n const active = FilterBar.activeFilters();\n if (active.length !== 1) return null;\n return active[0].op === 'equals' ? active[0] : null;\n },\n\n // Open `table` filtered to `column = value`, remembering where we came\n // from so Back can restore it exactly.\n open(tableName, column, value, label, push) {\n const target = State.tablesByName.get(tableName);\n if (!target) {\n toast('Unknown table: ' + tableName, 'err');\n return;\n }\n if (push && State.currentTable) {\n State.data.linkTrail.push({\n table: State.currentTable.name,\n filters: State.data.filters.map((f) => ({ ...f })),\n search: State.data.search,\n orderBy: State.data.orderBy,\n dir: State.data.dir,\n offset: State.data.offset,\n linkLabel: State.data.linkLabel,\n });\n }\n State.currentTable = target;\n State.data.offset = 0;\n State.data.search = '';\n $('#dataSearch').value = '';\n State.data.orderBy =\n (target.primaryKey && target.primaryKey[0]) || (target.columns[0] && target.columns[0].name);\n State.data.dir = 'asc';\n State.data.filters = [{ column, op: 'equals', value: String(value) }];\n State.data.selected.clear();\n State.data.linkLabel = label;\n Storage.set('currentTable', target.name);\n Sidebar.render();\n FilterBar.render();\n if (State.currentTab !== 'data') Router.selectTab('data');\n DataPane.loadAndRender();\n },\n\n // Follow the reference in one cell to the row it points at.\n follow(link, value) {\n if (value === null || value === undefined) return;\n const from = State.currentTable ? State.currentTable.name : '?';\n Nav.open(link.targetTable, link.targetColumn, value, from + '.' + link.relation, true);\n },\n\n // Jump the other way: the child rows referencing the pinned row.\n followChild(link, value) {\n const from = State.currentTable ? State.currentTable.name : '?';\n Nav.open(link.targetTable, link.targetColumn, value, from + '.' + link.relation, true);\n },\n\n back() {\n const prev = State.data.linkTrail.pop();\n if (!prev) return;\n const target = State.tablesByName.get(prev.table);\n if (!target) return;\n State.currentTable = target;\n State.data.filters = prev.filters.map((f) => ({ ...f }));\n State.data.search = prev.search || '';\n $('#dataSearch').value = State.data.search;\n State.data.orderBy = prev.orderBy;\n State.data.dir = prev.dir;\n State.data.offset = prev.offset || 0;\n State.data.linkLabel = prev.linkLabel || null;\n State.data.selected.clear();\n Storage.set('currentTable', target.name);\n Sidebar.render();\n FilterBar.render();\n DataPane.loadAndRender();\n },\n\n clearFilter() {\n State.data.filters = [];\n State.data.linkLabel = null;\n State.data.offset = 0;\n FilterBar.render();\n DataPane.loadAndRender();\n },\n\n // The bar above the filter bar: where we came from, what is pinned, and\n // the reverse jumps available from a pinned row.\n render() {\n const bar = $('#dataLinkBar');\n if (!bar) return;\n bar.innerHTML = '';\n const t = State.currentTable;\n const pinned = t ? Nav.pinnedFilter() : null;\n const trail = State.data.linkTrail;\n const children = pinned ? Nav.childLinks().filter((l) => l.column === pinned.column) : [];\n if (!t || (!trail.length && !State.data.linkLabel && !children.length)) {\n bar.hidden = true;\n return;\n }\n bar.hidden = false;\n\n if (trail.length) {\n const prev = trail[trail.length - 1];\n bar.appendChild(\n el('button', {\n class: 'btn btn-sm',\n text: '← Back to ' + prev.table,\n onclick: () => Nav.back(),\n })\n );\n }\n if (State.data.linkLabel) {\n bar.appendChild(el('span', { class: 'link-bar-sep', text: 'via' }));\n bar.appendChild(el('span', { class: 'link-bar-target', text: State.data.linkLabel }));\n }\n if (pinned) {\n bar.appendChild(el('span', { class: 'link-bar-sep', text: '·' }));\n bar.appendChild(\n el('span', { text: t.name + '.' + pinned.column + ' = ' + String(pinned.value) })\n );\n bar.appendChild(\n el('button', {\n class: 'btn btn-ghost btn-sm',\n text: 'Clear filter',\n onclick: () => Nav.clearFilter(),\n })\n );\n }\n if (children.length && pinned) {\n bar.appendChild(el('span', { class: 'link-bar-sep', text: 'referenced by' }));\n for (const link of children) {\n bar.appendChild(\n el('button', {\n class: 'btn btn-sm',\n text: link.targetTable + '.' + link.targetColumn + ' →',\n onclick: () => Nav.followChild(link, pinned.value),\n })\n );\n }\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 $('#dataLinkBar').hidden = true;\n $('#dataRange').textContent = '-';\n $('#pageIndicator').textContent = '-';\n return;\n }\n $('#dataTitle').textContent = t.name;\n $('#dataTotal').textContent = State.data.loading\n ? 'loading…'\n : formatNumber(State.data.total) + (State.data.total === 1 ? ' row' : ' rows');\n DataPane.renderSelectionBar();\n Nav.render();\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 // Foreign-key cells become links to the referenced row; a null\n // reference (or a column with no relation) renders exactly as before.\n cellLink: (col, val) => {\n const link = Nav.fkFor(col);\n if (!link || val === null || val === undefined) return null;\n return {\n title: 'Open ' + link.targetTable + ' where ' + link.targetColumn + ' = ' + String(val),\n onClick: () => Nav.follow(link, val),\n };\n },\n headerNote: (col) => {\n const link = Nav.fkFor(col);\n return link ? '→ ' + link.targetTable + '.' + link.targetColumn : null;\n },\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 ' + plural(res.rowCount || targets.length, 'row'), '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 // Which table the rows in State.data actually came from. The Data tab\n // renders whatever is in State.data, so without this a table picked\n // from another tab would render the PREVIOUS table's rows under the\n // new table's name until the next refresh.\n State.data.loadedTable = t.name;\n } catch (err) {\n State.data.error = err.message;\n State.data.rows = [];\n State.data.total = 0;\n State.data.loadedTable = t.name;\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 ' + plural(res.rowCount || rows.length, 'row'), '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 const note = opts.headerNote ? opts.headerNote(c) : null;\n if (note) th.appendChild(el('span', { class: 'th-note', text: note }));\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 const link = opts.cellLink ? opts.cellLink(c, val, row) : null;\n tr.appendChild(link ? Table.renderLinkCell(val, link) : 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 // A foreign-key cell: the value stays exactly as rendered elsewhere (raw\n // text, copyable via double-click) but is wrapped in a button that opens\n // the referenced row.\n renderLinkCell(val, link) {\n const s = String(val);\n const td = el('td', { class: 'cell-fk' });\n td.dataset.raw = s;\n td.appendChild(\n el(\n 'button',\n {\n class: 'fk-link',\n title: link.title,\n onclick: (e) => {\n e.stopPropagation();\n link.onClick();\n },\n },\n [s, el('span', { class: 'fk-arrow', text: '↗' })]\n )\n );\n return td;\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 // \"Field\" is the camelCase name a QUERY uses (`where: { createdAt: ... }`).\n // The schema view showed the TS type but never the TS name, so the only\n // spelling on screen was the database column one, which no query key\n // accepts.\n colHead.appendChild(\n el('tr', {}, ['Column', 'Field', '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(\n el('td', {}, [\n el('span', {\n class: 'col-field',\n text: c.field || c.name,\n title: 'The name to use in a query (where / orderBy / select)',\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 // Clearing `result` alone left the PREVIOUS table's rows on screen (only\n // a run repaints the grid), so the pane showed a query for one table\n // above the rows of another.\n BuilderPane.renderResults();\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 // select and omit are mutually exclusive on the ORM side, so\n // picking a select chip switches modes: the whole omit list\n // clears, not just this column.\n args.omit = [];\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 // Mirror of the select chips: omitting switches modes.\n args.select = [];\n }\n BuilderPane.render();\n },\n })\n );\n }\n section.appendChild(chipsOm);\n return section;\n },\n\n // ---------- Spec assembly / validation ----------\n\n // Assembly lives in `BuilderArgs` (see the builder-args region above), so\n // the emit side and the saved-query hydrate side are one implementation\n // that cannot drift apart. These stay as the pane's call sites.\n fieldName(tableMeta, columnName) {\n return BuilderArgs.fieldName(tableMeta, columnName);\n },\n\n buildFindManyArgs(localArgs, tableMeta) {\n return BuilderArgs.buildFindManyArgs(localArgs, tableMeta, BuilderPane.findTable);\n },\n\n assembleWhere(localArgs, tableMeta) {\n return BuilderArgs.assembleWhere(localArgs, tableMeta);\n },\n\n coerceValue(val, tsType, op) {\n return BuilderArgs.coerceValue(val, tsType, op);\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 // Keys that name no column. The controls cannot produce one, a saved\n // query can (a renamed or dropped column, or a file saved against a\n // different schema), and the old behaviour was to drop the clause on\n // the way to the server and run the query unfiltered. Run and Save are\n // refused instead, at every depth, naming the key.\n const tableMetaForValidation = BuilderPane.findTable(State.builder.table);\n BuilderArgs.unresolvedColumns(\n State.builder.args,\n tableMetaForValidation,\n BuilderPane.findTable,\n ).forEach((entry, i) => {\n errs['unknown' + i] = BuilderArgs.describeUnresolved(entry);\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 the query to see rows here.</div>';\n return;\n }\n header.textContent =\n 'Results · ' + formatNumber(r.rowCount) + (r.rowCount === 1 ? ' row · ' : ' 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 // The pane's internal shape (column names, arrays) differs from the\n // flat field-keyed args object on disk, so loading is a real inverse\n // (BuilderArgs.hydrate) and anything it cannot resolve is REPORTED.\n // A saved query that quietly loads without its WHERE clauses returns\n // more rows than it did when it was saved.\n State.builder.table = q.table;\n const issues = [];\n State.builder.args = SavedQueries.hydrateBuilderArgs(q.args || {}, q.table, issues);\n BuilderPane.render();\n if (issues.length) {\n toast(\n 'Loaded \"' + q.name + '\" with ' + issues.length + ' unresolved item(s): ' + issues.join(' · '),\n 'err'\n );\n } else {\n toast('Loaded saved builder: ' + q.name, 'ok');\n }\n }\n },\n\n hydrateBuilderArgs(argsObj, tableName, issues) {\n const tableMeta = State.tablesByName.get(tableName);\n if (!tableMeta) {\n if (issues) issues.push('table \"' + tableName + '\" is not in this schema');\n return emptyBuilderArgs();\n }\n return BuilderArgs.hydrate(argsObj, tableMeta, BuilderPane.findTable, issues);\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 // Fail-closed indicator: the server found a generated metadata file it\n // could not read tags out of, so it tagged every column instead of\n // trusting an empty result.\n State.piiScanFailed = schema.piiScanFailed || null;\n $('#piiScanBanner').hidden = !State.piiScanFailed;\n if (State.piiScanFailed) {\n $('#piiScanBannerWhy').textContent =\n 'could not read PII tags from ' + State.piiScanFailed.path + ' (' + State.piiScanFailed.reason + '), ' +\n 'so all ' + State.piiScanFailed.columns + ' columns are redacted, sort / search / filter are refused, ' +\n 'and the Query tab is disabled. Re-run `turbine generate`, or restart with --show-pii.';\n }\n // Persistent unredacted-PII indicator. In demo mode the in-UI pill can\n // flip it, so the reason line must not blame a flag that was not passed.\n $('#piiBanner').hidden = !State.showPii;\n $('#piiBannerWhy').textContent = State.demo\n ? 'PII-tagged column values are displayed unredacted (switched on in this demo session)'\n : 'PII-tagged column values are displayed unredacted (--show-pii)';\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";
|