turbine-orm 0.18.0 → 0.19.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.
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// AUTO-GENERATED from src/cli/studio-ui.html. Do not edit by hand.
|
|
2
2
|
// Regenerate via: node scripts/build-studio-ui.mjs
|
|
3
|
-
export const 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 <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin />\n <link\n href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap\"\n rel=\"stylesheet\"\n />\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 Reset + base\n ===================================================================== */\n *,\n *::before,\n *::after {\n box-sizing: border-box;\n }\n html,\n body {\n margin: 0;\n padding: 0;\n height: 100%;\n background: var(--bg);\n color: var(--text);\n font-family: var(--sans);\n font-size: 13px;\n line-height: 1.5;\n -webkit-font-smoothing: antialiased;\n text-rendering: optimizeLegibility;\n }\n body {\n overflow: hidden;\n }\n button {\n font-family: inherit;\n font-size: inherit;\n color: inherit;\n background: none;\n border: none;\n cursor: pointer;\n padding: 0;\n }\n input,\n textarea,\n select {\n font-family: inherit;\n font-size: inherit;\n color: inherit;\n }\n ::selection {\n background: var(--accent-dim);\n color: var(--text);\n }\n ::-webkit-scrollbar {\n width: 10px;\n height: 10px;\n }\n ::-webkit-scrollbar-track {\n background: transparent;\n }\n ::-webkit-scrollbar-thumb {\n background: #2a2a30;\n border-radius: 10px;\n border: 2px solid var(--bg);\n }\n ::-webkit-scrollbar-thumb:hover {\n background: #3a3a42;\n }\n :focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n border-radius: 3px;\n }\n a {\n color: var(--accent);\n text-decoration: none;\n }\n a:hover {\n color: var(--accent-hover);\n }\n\n /* =====================================================================\n App shell\n ===================================================================== */\n .app {\n display: grid;\n grid-template-rows: var(--header-h) 1fr;\n height: 100vh;\n width: 100vw;\n }\n\n /* Header */\n .header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 0 16px;\n border-bottom: 1px solid var(--border);\n background: var(--bg-elev);\n height: var(--header-h);\n user-select: none;\n }\n .brand {\n display: flex;\n align-items: center;\n gap: 10px;\n font-family: var(--mono);\n font-size: 13px;\n font-weight: 600;\n letter-spacing: -0.01em;\n }\n .brand-mark {\n width: 18px;\n height: 18px;\n border-radius: 4px;\n background: linear-gradient(135deg, var(--accent) 0%, var(--purple) 100%);\n position: relative;\n }\n .brand-mark::after {\n content: \"\";\n position: absolute;\n inset: 4px;\n border-radius: 2px;\n background: var(--bg-elev);\n }\n .brand-dot {\n color: var(--text-muted);\n }\n .brand-sub {\n color: var(--text-dim);\n font-weight: 400;\n }\n .header-right {\n display: flex;\n align-items: center;\n gap: 14px;\n font-size: 12px;\n color: var(--text-dim);\n }\n .header-meta {\n font-family: var(--mono);\n font-size: 11.5px;\n }\n .header-meta .sep {\n color: var(--text-muted);\n margin: 0 6px;\n }\n .conn {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n color: var(--text-dim);\n }\n .conn-dot {\n width: 7px;\n height: 7px;\n border-radius: 50%;\n background: var(--text-muted);\n transition: background var(--t), box-shadow var(--t);\n }\n .conn-dot.ok {\n background: var(--green);\n box-shadow: 0 0 0 3px rgba(74, 222, 128, 0.15);\n }\n .conn-dot.err {\n background: var(--red);\n box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.15);\n }\n .kbd {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n min-width: 18px;\n height: 18px;\n padding: 0 5px;\n border: 1px solid var(--border-strong);\n border-bottom-width: 2px;\n border-radius: 4px;\n background: var(--bg-elev);\n font-family: var(--mono);\n font-size: 10.5px;\n color: var(--text-dim);\n }\n .header-cmd {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 4px 8px 4px 10px;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: var(--bg);\n color: var(--text-dim);\n font-size: 12px;\n transition: border-color var(--t), color var(--t);\n }\n .header-cmd:hover {\n border-color: var(--border-strong);\n color: var(--text);\n }\n .icon-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n border-radius: var(--radius);\n color: var(--text-dim);\n transition: background var(--t), color var(--t);\n }\n .icon-btn:hover {\n background: var(--bg-hover);\n color: var(--text);\n }\n .hamburger {\n display: none;\n }\n\n /* Body layout */\n .body {\n display: grid;\n grid-template-columns: var(--sidebar-w) 1fr;\n min-height: 0;\n overflow: hidden;\n position: relative;\n }\n\n /* =====================================================================\n Sidebar\n ===================================================================== */\n .sidebar {\n display: flex;\n flex-direction: column;\n border-right: 1px solid var(--border);\n background: var(--bg-elev);\n min-width: 0;\n position: relative;\n }\n .sidebar-header {\n padding: 14px 16px 10px;\n border-bottom: 1px solid var(--border);\n }\n .sidebar-title {\n font-family: var(--mono);\n font-size: 11px;\n font-weight: 600;\n color: var(--text);\n letter-spacing: 0.02em;\n text-transform: lowercase;\n display: flex;\n align-items: center;\n gap: 6px;\n }\n .sidebar-meta {\n font-family: var(--mono);\n font-size: 10.5px;\n color: var(--text-muted);\n margin-top: 4px;\n letter-spacing: 0.02em;\n }\n .sidebar-search {\n margin: 10px 12px 4px;\n position: relative;\n }\n .sidebar-search input {\n width: 100%;\n padding: 7px 10px 7px 28px;\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n color: var(--text);\n font-size: 12px;\n transition: border-color var(--t), background var(--t);\n }\n .sidebar-search input:focus {\n outline: none;\n border-color: var(--border-strong);\n }\n .sidebar-search input::placeholder {\n color: var(--text-muted);\n }\n .sidebar-search-icon {\n position: absolute;\n left: 9px;\n top: 50%;\n transform: translateY(-50%);\n color: var(--text-muted);\n width: 13px;\n height: 13px;\n pointer-events: none;\n }\n .sidebar-scroll {\n flex: 1;\n overflow-y: auto;\n padding: 8px 0 16px;\n }\n .sidebar-group {\n padding: 4px 0;\n }\n .sidebar-group-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 16px 4px;\n font-family: var(--mono);\n font-size: 10px;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n cursor: pointer;\n user-select: none;\n transition: color var(--t);\n }\n .sidebar-group-header:hover {\n color: var(--text-dim);\n }\n .sidebar-group-header .chev {\n transition: transform var(--t);\n }\n .sidebar-group.collapsed .chev {\n transform: rotate(-90deg);\n }\n .sidebar-group.collapsed .sidebar-group-body {\n display: none;\n }\n .sidebar-group-body {\n display: flex;\n flex-direction: column;\n }\n .sidebar-item {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 5px 16px 5px 22px;\n font-size: 12.5px;\n font-family: var(--mono);\n color: var(--text-dim);\n cursor: pointer;\n transition: background var(--t), color var(--t);\n user-select: none;\n min-width: 0;\n }\n .sidebar-item-name {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n min-width: 0;\n }\n .sidebar-item-count {\n margin-left: 8px;\n font-size: 10.5px;\n color: var(--text-muted);\n flex-shrink: 0;\n }\n .sidebar-item:hover {\n background: var(--bg-hover);\n color: var(--text);\n }\n .sidebar-item.active {\n background: var(--bg-active);\n color: var(--text);\n border-left: 2px solid var(--accent);\n padding-left: 20px;\n }\n .sidebar-item.active .sidebar-item-count {\n color: var(--text-dim);\n }\n .sidebar-empty {\n padding: 6px 22px;\n font-size: 11.5px;\n color: var(--text-muted);\n font-style: italic;\n }\n\n .saved-group {\n margin-top: 6px;\n }\n .saved-table-header {\n padding: 6px 16px 3px 16px;\n font-family: var(--mono);\n font-size: 10px;\n color: var(--text-muted);\n text-transform: lowercase;\n letter-spacing: 0.02em;\n }\n .saved-query {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 4px 16px 4px 22px;\n font-size: 12px;\n color: var(--text-dim);\n cursor: pointer;\n transition: background var(--t), color var(--t);\n }\n .saved-query:hover {\n background: var(--bg-hover);\n color: var(--text);\n }\n .saved-query .saved-kind {\n font-family: var(--mono);\n font-size: 9px;\n padding: 1px 5px;\n border-radius: 3px;\n border: 1px solid var(--border);\n color: var(--text-muted);\n text-transform: uppercase;\n }\n .saved-query-name {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n flex: 1;\n }\n .saved-query-del {\n opacity: 0;\n transition: opacity var(--t);\n color: var(--text-muted);\n padding: 0 2px;\n }\n .saved-query:hover .saved-query-del {\n opacity: 1;\n }\n .saved-query-del:hover {\n color: var(--red);\n }\n\n /* Sidebar resizer */\n .sidebar-resizer {\n position: absolute;\n top: 0;\n right: -3px;\n width: 6px;\n height: 100%;\n cursor: col-resize;\n z-index: 10;\n user-select: none;\n }\n .sidebar-resizer:hover::after,\n .sidebar-resizer.dragging::after {\n content: \"\";\n position: absolute;\n right: 2px;\n top: 0;\n width: 2px;\n height: 100%;\n background: var(--accent);\n }\n\n /* =====================================================================\n Main area\n ===================================================================== */\n .main {\n display: flex;\n flex-direction: column;\n min-width: 0;\n min-height: 0;\n overflow: hidden;\n }\n .tabs {\n display: flex;\n align-items: center;\n gap: 2px;\n padding: 0 16px;\n border-bottom: 1px solid var(--border);\n background: var(--bg-elev);\n height: 38px;\n flex-shrink: 0;\n }\n .tab {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n padding: 0 12px;\n height: 38px;\n font-size: 12.5px;\n color: var(--text-dim);\n border-bottom: 2px solid transparent;\n margin-bottom: -1px;\n transition: color var(--t), border-color var(--t);\n user-select: none;\n }\n .tab:hover {\n color: var(--text);\n }\n .tab.active {\n color: var(--text);\n border-bottom-color: var(--accent);\n }\n .tab-icon {\n width: 13px;\n height: 13px;\n opacity: 0.7;\n }\n .tab.active .tab-icon {\n opacity: 1;\n }\n\n .pane {\n flex: 1;\n min-height: 0;\n display: none;\n flex-direction: column;\n overflow: hidden;\n }\n .pane.active {\n display: flex;\n }\n .pane-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 16px;\n padding: 14px 20px;\n border-bottom: 1px solid var(--border);\n flex-shrink: 0;\n }\n .pane-title {\n display: flex;\n align-items: baseline;\n gap: 10px;\n min-width: 0;\n }\n .pane-title h1 {\n margin: 0;\n font-size: 18px;\n font-weight: 600;\n font-family: var(--mono);\n letter-spacing: -0.01em;\n color: var(--text);\n }\n .pane-title .muted {\n font-size: 12px;\n color: var(--text-muted);\n font-family: var(--mono);\n }\n .toolbar {\n display: flex;\n align-items: center;\n gap: 8px;\n }\n .search-input {\n position: relative;\n }\n .search-input input {\n padding: 6px 10px 6px 28px;\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n color: var(--text);\n font-size: 12px;\n width: 220px;\n transition: border-color var(--t), width var(--t);\n font-family: var(--mono);\n }\n .search-input input:focus {\n outline: none;\n border-color: var(--border-strong);\n width: 280px;\n }\n .search-input-icon {\n position: absolute;\n left: 9px;\n top: 50%;\n transform: translateY(-50%);\n color: var(--text-muted);\n width: 13px;\n height: 13px;\n pointer-events: none;\n }\n\n /* Buttons */\n .btn {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 6px 12px;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: var(--bg);\n color: var(--text);\n font-size: 12px;\n transition: all var(--t);\n white-space: nowrap;\n }\n .btn:hover {\n background: var(--bg-hover);\n border-color: var(--border-strong);\n }\n .btn:disabled {\n opacity: 0.5;\n cursor: not-allowed;\n }\n .btn-primary {\n background: var(--accent);\n border-color: var(--accent);\n color: #0a0a0b;\n font-weight: 500;\n }\n .btn-primary:hover:not(:disabled) {\n background: var(--accent-hover);\n border-color: var(--accent-hover);\n }\n .btn-ghost {\n border-color: transparent;\n background: transparent;\n color: var(--text-dim);\n }\n .btn-ghost:hover {\n background: var(--bg-hover);\n color: var(--text);\n }\n .btn-sm {\n padding: 4px 8px;\n font-size: 11px;\n }\n\n /* =====================================================================\n Data table\n ===================================================================== */\n .data-wrap {\n flex: 1;\n min-height: 0;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n .data-scroll {\n flex: 1;\n overflow: auto;\n min-height: 0;\n position: relative;\n }\n table.data {\n border-collapse: separate;\n border-spacing: 0;\n width: 100%;\n font-family: var(--mono);\n font-size: 12px;\n }\n table.data thead th {\n position: sticky;\n top: 0;\n background: var(--bg-elev);\n border-bottom: 1px solid var(--border);\n padding: 8px 14px;\n text-align: left;\n font-weight: 500;\n color: var(--text-dim);\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n white-space: nowrap;\n z-index: 2;\n user-select: none;\n }\n table.data thead th.sortable {\n cursor: pointer;\n transition: color var(--t), background var(--t);\n }\n table.data thead th.sortable:hover {\n color: var(--text);\n background: var(--bg-hover);\n }\n table.data thead th .sort-ind {\n color: var(--accent);\n margin-left: 4px;\n font-size: 10px;\n }\n table.data tbody td {\n padding: 7px 14px;\n border-bottom: 1px solid var(--border);\n color: var(--text);\n max-width: 360px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n vertical-align: middle;\n }\n table.data tbody tr {\n transition: background var(--t);\n }\n table.data tbody tr:hover {\n background: var(--bg-hover);\n }\n .cell-null {\n color: var(--text-muted);\n font-style: italic;\n }\n .cell-json {\n color: var(--purple);\n }\n .cell-json .expand-btn {\n margin-left: 6px;\n font-size: 10px;\n color: var(--text-muted);\n padding: 1px 4px;\n border: 1px solid var(--border);\n border-radius: 3px;\n transition: all var(--t);\n }\n .cell-json .expand-btn:hover {\n color: var(--accent);\n border-color: var(--accent);\n }\n .cell-number {\n color: var(--green);\n }\n .cell-bool {\n color: var(--orange);\n }\n .data-footer {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 10px 20px;\n border-top: 1px solid var(--border);\n background: var(--bg-elev);\n font-size: 11.5px;\n color: var(--text-dim);\n flex-shrink: 0;\n font-family: var(--mono);\n }\n .pagination {\n display: flex;\n align-items: center;\n gap: 8px;\n }\n\n /* States */\n .empty,\n .error-box,\n .loading {\n padding: 40px 24px;\n text-align: center;\n color: var(--text-dim);\n font-size: 13px;\n }\n .empty-icon {\n margin: 0 auto 10px;\n width: 28px;\n height: 28px;\n color: var(--text-muted);\n }\n .error-box {\n margin: 16px;\n padding: 14px 18px;\n border: 1px solid rgba(248, 113, 113, 0.35);\n background: rgba(248, 113, 113, 0.06);\n border-radius: var(--radius);\n color: var(--red);\n text-align: left;\n font-family: var(--mono);\n font-size: 12px;\n white-space: pre-wrap;\n }\n .error-box-title {\n font-weight: 600;\n margin-bottom: 4px;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n font-size: 10.5px;\n }\n\n /* Skeleton */\n @keyframes shimmer {\n 0% {\n background-position: -200% 0;\n }\n 100% {\n background-position: 200% 0;\n }\n }\n .skeleton-row {\n display: flex;\n gap: 16px;\n padding: 10px 14px;\n border-bottom: 1px solid var(--border);\n }\n .skeleton-cell {\n height: 10px;\n border-radius: 3px;\n background: linear-gradient(\n 90deg,\n #1a1a1d 0%,\n #22222a 50%,\n #1a1a1d 100%\n );\n background-size: 200% 100%;\n animation: shimmer 1.4s linear infinite;\n flex: 1;\n }\n\n /* =====================================================================\n Schema pane\n ===================================================================== */\n .schema-scroll {\n flex: 1;\n overflow: auto;\n padding: 24px 32px 40px;\n }\n .schema-section {\n margin-bottom: 32px;\n }\n .schema-section h2 {\n font-size: 11px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-dim);\n margin: 0 0 12px;\n font-family: var(--mono);\n }\n .col-table {\n width: 100%;\n border-collapse: separate;\n border-spacing: 0;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n overflow: hidden;\n background: var(--bg-elev);\n }\n .col-table th,\n .col-table td {\n padding: 9px 14px;\n text-align: left;\n font-family: var(--mono);\n font-size: 12px;\n border-bottom: 1px solid var(--border);\n }\n .col-table th {\n font-size: 10.5px;\n font-weight: 500;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n color: var(--text-muted);\n background: var(--bg);\n }\n .col-table tr:last-child td {\n border-bottom: none;\n }\n .col-name {\n color: var(--text);\n font-weight: 500;\n }\n .col-name.pk {\n color: var(--orange);\n }\n .col-ts {\n color: var(--green);\n }\n .col-pg {\n color: var(--text-dim);\n }\n .col-rel-name {\n color: var(--accent);\n }\n .badge {\n display: inline-block;\n padding: 1px 6px;\n border-radius: 3px;\n font-size: 9.5px;\n font-weight: 500;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n margin-right: 4px;\n font-family: var(--mono);\n border: 1px solid transparent;\n }\n .badge.pk {\n background: rgba(251, 146, 60, 0.12);\n color: var(--orange);\n border-color: rgba(251, 146, 60, 0.25);\n }\n .badge.nn {\n background: rgba(248, 113, 113, 0.1);\n color: var(--red);\n border-color: rgba(248, 113, 113, 0.2);\n }\n .badge.def {\n background: rgba(167, 139, 250, 0.1);\n color: var(--purple);\n border-color: rgba(167, 139, 250, 0.2);\n }\n .badge.nullable {\n background: var(--bg);\n color: var(--text-muted);\n border-color: var(--border);\n }\n .badge.rel-type {\n background: var(--accent-dim);\n color: var(--accent);\n border-color: rgba(96, 165, 250, 0.25);\n }\n .schema-details {\n margin-top: 18px;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: var(--bg-elev);\n overflow: hidden;\n }\n .schema-details summary {\n padding: 10px 14px;\n font-family: var(--mono);\n font-size: 11px;\n color: var(--text-dim);\n cursor: pointer;\n user-select: none;\n list-style: none;\n display: flex;\n align-items: center;\n gap: 8px;\n }\n .schema-details summary::-webkit-details-marker {\n display: none;\n }\n .schema-details summary::before {\n content: \"›\";\n color: var(--text-muted);\n transition: transform var(--t);\n display: inline-block;\n }\n .schema-details[open] summary::before {\n transform: rotate(90deg);\n }\n .schema-details pre {\n margin: 0;\n padding: 14px 16px;\n font-family: var(--mono);\n font-size: 11.5px;\n line-height: 1.6;\n color: var(--text-dim);\n overflow-x: auto;\n border-top: 1px solid var(--border);\n background: var(--bg);\n }\n .sql-kw {\n color: var(--accent);\n font-weight: 500;\n }\n .sql-type {\n color: var(--green);\n }\n .sql-ident {\n color: var(--text);\n }\n .sql-str {\n color: var(--yellow);\n }\n\n /* =====================================================================\n SQL pane\n ===================================================================== */\n .sql-pane {\n display: flex;\n flex-direction: column;\n min-height: 0;\n }\n .sql-editor-wrap {\n display: flex;\n flex-direction: column;\n border-bottom: 1px solid var(--border);\n padding: 14px 20px;\n gap: 10px;\n }\n .sql-toolbar {\n display: flex;\n justify-content: space-between;\n align-items: center;\n }\n .sql-toolbar-left {\n display: flex;\n gap: 8px;\n align-items: center;\n }\n .sql-editor {\n width: 100%;\n min-height: 140px;\n max-height: 320px;\n background: var(--bg-elev);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 12px 14px;\n font-family: var(--mono);\n font-size: 12.5px;\n color: var(--text);\n line-height: 1.55;\n resize: vertical;\n transition: border-color var(--t);\n tab-size: 2;\n }\n .sql-editor:focus {\n outline: none;\n border-color: var(--border-strong);\n }\n .sql-editor::placeholder {\n color: var(--text-muted);\n }\n .sql-meta {\n padding: 10px 20px;\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 display: flex;\n gap: 14px;\n flex-shrink: 0;\n }\n .sql-meta .sep {\n color: var(--text-muted);\n }\n .sql-meta .ok {\n color: var(--green);\n }\n\n /* =====================================================================\n Builder pane\n ===================================================================== */\n .builder-wrap {\n flex: 1;\n min-height: 0;\n display: grid;\n grid-template-columns: 1fr 1fr;\n min-width: 0;\n }\n .builder-left {\n border-right: 1px solid var(--border);\n overflow-y: auto;\n padding: 20px 24px;\n }\n .builder-right {\n display: flex;\n flex-direction: column;\n min-height: 0;\n background: var(--bg);\n min-width: 0;\n }\n .builder-preview {\n padding: 18px 22px;\n font-family: var(--mono);\n font-size: 12.5px;\n line-height: 1.65;\n color: var(--text);\n white-space: pre;\n overflow: auto;\n flex: 1;\n min-height: 0;\n border-bottom: 1px solid var(--border);\n }\n .tok-kw {\n color: var(--accent);\n }\n .tok-fn {\n color: var(--yellow);\n }\n .tok-str {\n color: var(--green);\n }\n .tok-num {\n color: var(--orange);\n }\n .tok-key {\n color: var(--purple);\n }\n .tok-punc {\n color: var(--text-dim);\n }\n .tok-comment {\n color: var(--text-muted);\n font-style: italic;\n }\n .builder-results {\n flex: 1;\n min-height: 200px;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n .builder-results-header {\n padding: 10px 22px;\n font-family: var(--mono);\n font-size: 11px;\n color: var(--text-dim);\n border-bottom: 1px solid var(--border);\n background: var(--bg-elev);\n text-transform: uppercase;\n letter-spacing: 0.04em;\n flex-shrink: 0;\n }\n\n .builder-section {\n margin-bottom: 18px;\n }\n .builder-section-title {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding-bottom: 8px;\n margin-bottom: 10px;\n border-bottom: 1px dashed var(--border);\n }\n .builder-section-title h3 {\n margin: 0;\n font-size: 11px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.06em;\n color: var(--text-dim);\n font-family: var(--mono);\n }\n .builder-field {\n margin-bottom: 10px;\n }\n .builder-field label {\n display: block;\n font-size: 11px;\n color: var(--text-muted);\n margin-bottom: 4px;\n font-family: var(--mono);\n text-transform: uppercase;\n letter-spacing: 0.04em;\n }\n .builder-input,\n .builder-select {\n width: 100%;\n padding: 6px 10px;\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n color: var(--text);\n font-size: 12px;\n font-family: var(--mono);\n transition: border-color var(--t);\n }\n .builder-input:focus,\n .builder-select:focus {\n outline: none;\n border-color: var(--border-strong);\n }\n .builder-input.invalid,\n .builder-select.invalid {\n border-color: rgba(248, 113, 113, 0.5);\n }\n .field-hint {\n font-size: 10.5px;\n color: var(--red);\n margin-top: 3px;\n font-family: var(--mono);\n }\n .where-clauses {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n .where-clause {\n display: grid;\n grid-template-columns: 1fr 1fr 1.2fr auto;\n gap: 6px;\n align-items: center;\n }\n .where-clause .btn-ghost {\n padding: 4px 6px;\n }\n .clause-row {\n display: flex;\n gap: 6px;\n align-items: center;\n }\n .combinator-pick {\n display: flex;\n gap: 0;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n overflow: hidden;\n background: var(--bg);\n }\n .combinator-pick button {\n padding: 4px 10px;\n font-family: var(--mono);\n font-size: 10.5px;\n color: var(--text-dim);\n border-right: 1px solid var(--border);\n transition: background var(--t), color var(--t);\n }\n .combinator-pick button:last-child {\n border-right: none;\n }\n .combinator-pick button.active {\n background: var(--accent-dim);\n color: var(--accent);\n }\n .combinator-pick button:hover:not(.active) {\n background: var(--bg-hover);\n }\n .with-rels {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n .with-rel {\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: var(--bg);\n }\n .with-rel-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 7px 10px;\n cursor: pointer;\n user-select: none;\n gap: 8px;\n }\n .with-rel-header:hover {\n background: var(--bg-hover);\n }\n .with-rel-name {\n font-family: var(--mono);\n font-size: 11.5px;\n color: var(--accent);\n }\n .with-rel-meta {\n font-family: var(--mono);\n font-size: 10px;\n color: var(--text-muted);\n }\n .with-rel-body {\n padding: 10px;\n border-top: 1px solid var(--border);\n display: none;\n }\n .with-rel.expanded .with-rel-body {\n display: block;\n }\n .with-rel .chev {\n transition: transform var(--t);\n color: var(--text-muted);\n }\n .with-rel.expanded .chev {\n transform: rotate(90deg);\n }\n .chip-row {\n display: flex;\n gap: 6px;\n flex-wrap: wrap;\n }\n .chip {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n padding: 3px 8px;\n border: 1px solid var(--border);\n border-radius: 20px;\n background: var(--bg);\n font-family: var(--mono);\n font-size: 10.5px;\n color: var(--text-dim);\n cursor: pointer;\n transition: all var(--t);\n }\n .chip:hover {\n border-color: var(--border-strong);\n color: var(--text);\n }\n .chip.active {\n background: var(--accent-dim);\n border-color: rgba(96, 165, 250, 0.35);\n color: var(--accent);\n }\n\n /* =====================================================================\n Modal / dialog\n ===================================================================== */\n .modal-backdrop {\n position: fixed;\n inset: 0;\n background: rgba(0, 0, 0, 0.65);\n display: none;\n align-items: center;\n justify-content: center;\n z-index: 100;\n backdrop-filter: blur(4px);\n }\n .modal-backdrop.open {\n display: flex;\n animation: fade-in 150ms var(--ease);\n }\n @keyframes fade-in {\n from {\n opacity: 0;\n }\n to {\n opacity: 1;\n }\n }\n @keyframes slide-up {\n from {\n opacity: 0;\n transform: translateY(8px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n }\n .modal {\n background: var(--bg-elev);\n border: 1px solid var(--border);\n border-radius: var(--radius-lg);\n box-shadow: var(--shadow-lg);\n min-width: 400px;\n max-width: 90vw;\n max-height: 85vh;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n animation: slide-up 180ms var(--ease);\n }\n .modal-header {\n padding: 14px 18px;\n border-bottom: 1px solid var(--border);\n display: flex;\n align-items: center;\n justify-content: space-between;\n }\n .modal-title {\n font-size: 13px;\n font-weight: 600;\n color: var(--text);\n font-family: var(--mono);\n }\n .modal-body {\n padding: 16px 18px;\n overflow-y: auto;\n flex: 1;\n min-height: 0;\n }\n .modal-footer {\n padding: 12px 18px;\n border-top: 1px solid var(--border);\n display: flex;\n justify-content: flex-end;\n gap: 8px;\n }\n .modal-field {\n margin-bottom: 12px;\n }\n .modal-field label {\n display: block;\n font-size: 11px;\n color: var(--text-dim);\n margin-bottom: 5px;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n font-family: var(--mono);\n }\n .modal-field input,\n .modal-field select {\n width: 100%;\n padding: 7px 10px;\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n color: var(--text);\n font-family: var(--mono);\n font-size: 12px;\n }\n .modal-field input:focus,\n .modal-field select:focus {\n outline: none;\n border-color: var(--border-strong);\n }\n .json-view {\n font-family: var(--mono);\n font-size: 12px;\n color: var(--text);\n white-space: pre-wrap;\n word-break: break-word;\n }\n\n /* =====================================================================\n Command palette\n ===================================================================== */\n .palette {\n width: 560px;\n max-width: 92vw;\n }\n .palette-input {\n width: 100%;\n padding: 14px 18px;\n background: transparent;\n border: none;\n border-bottom: 1px solid var(--border);\n color: var(--text);\n font-size: 14px;\n font-family: var(--sans);\n }\n .palette-input:focus {\n outline: none;\n }\n .palette-input::placeholder {\n color: var(--text-muted);\n }\n .palette-list {\n max-height: 50vh;\n overflow-y: auto;\n padding: 6px 0;\n }\n .palette-section {\n padding: 8px 16px 4px;\n font-size: 10px;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n font-family: var(--mono);\n }\n .palette-item {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 16px;\n cursor: pointer;\n transition: background var(--t);\n font-size: 13px;\n }\n .palette-item:hover,\n .palette-item.selected {\n background: var(--bg-hover);\n }\n .palette-item .pi-name {\n display: flex;\n align-items: center;\n gap: 10px;\n }\n .palette-item .pi-icon {\n color: var(--text-dim);\n width: 14px;\n height: 14px;\n }\n .palette-item .pi-kbd {\n display: flex;\n gap: 3px;\n }\n\n /* =====================================================================\n Responsive\n ===================================================================== */\n @media (max-width: 1024px) {\n .body {\n grid-template-columns: 1fr;\n }\n .sidebar {\n position: fixed;\n top: var(--header-h);\n left: 0;\n bottom: 0;\n width: 280px;\n transform: translateX(-100%);\n transition: transform 200ms var(--ease);\n z-index: 50;\n }\n .sidebar.open {\n transform: translateX(0);\n }\n .hamburger {\n display: inline-flex;\n }\n .sidebar-resizer {\n display: none;\n }\n .search-input input {\n width: 160px;\n }\n .search-input input:focus {\n width: 200px;\n }\n }\n\n .toast-wrap {\n position: fixed;\n bottom: 24px;\n right: 24px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n z-index: 200;\n }\n .toast {\n padding: 10px 14px;\n background: var(--bg-elev);\n border: 1px solid var(--border-strong);\n border-radius: var(--radius);\n font-size: 12px;\n color: var(--text);\n box-shadow: var(--shadow-lg);\n animation: slide-up 200ms var(--ease);\n max-width: 320px;\n }\n .toast.ok {\n border-left: 3px solid var(--green);\n }\n .toast.err {\n border-left: 3px solid var(--red);\n }\n </style>\n</head>\n<body>\n <div class=\"app\" id=\"app\">\n <!-- 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=\"data\" role=\"tab\" aria-selected=\"true\">\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 <button class=\"tab\" data-tab=\"sql\" role=\"tab\">\n <svg class=\"tab-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <polyline points=\"16 18 22 12 16 6\" />\n <polyline points=\"8 6 2 12 8 18\" />\n </svg>\n SQL\n </button>\n <button class=\"tab\" data-tab=\"builder\" role=\"tab\">\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 Builder\n </button>\n </nav>\n\n <!-- Data pane -->\n <section class=\"pane active\" id=\"pane-data\" role=\"tabpanel\">\n <div class=\"pane-header\">\n <div class=\"pane-title\">\n <h1 id=\"dataTitle\">—</h1>\n <span class=\"muted\" id=\"dataTotal\"></span>\n </div>\n <div class=\"toolbar\">\n <div class=\"search-input\">\n <svg class=\"search-input-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <circle cx=\"11\" cy=\"11\" r=\"7\" />\n <path d=\"m21 21-4.3-4.3\" />\n </svg>\n <input id=\"dataSearch\" type=\"text\" placeholder=\"Search all text columns…\" aria-label=\"Full text search\" />\n </div>\n <button class=\"btn btn-ghost btn-sm\" id=\"dataRefresh\" aria-label=\"Refresh\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <path d=\"M21 12a9 9 0 1 1-3-6.7L21 8\" />\n <path d=\"M21 3v5h-5\" />\n </svg>\n Refresh\n </button>\n </div>\n </div>\n <div class=\"data-wrap\">\n <div class=\"data-scroll\" id=\"dataScroll\"></div>\n <div class=\"data-footer\">\n <span id=\"dataRange\">—</span>\n <div class=\"pagination\">\n <button class=\"btn btn-sm\" id=\"pagePrev\">← Prev</button>\n <span id=\"pageIndicator\">—</span>\n <button class=\"btn btn-sm\" id=\"pageNext\">Next →</button>\n </div>\n </div>\n </div>\n </section>\n\n <!-- Schema pane -->\n <section class=\"pane\" id=\"pane-schema\" role=\"tabpanel\">\n <div class=\"pane-header\">\n <div class=\"pane-title\">\n <h1 id=\"schemaTitle\">—</h1>\n <span class=\"muted\" id=\"schemaSub\"></span>\n </div>\n </div>\n <div class=\"schema-scroll\" id=\"schemaScroll\"></div>\n </section>\n\n <!-- SQL pane -->\n <section class=\"pane sql-pane\" id=\"pane-sql\" role=\"tabpanel\">\n <div class=\"pane-header\">\n <div class=\"pane-title\">\n <h1>SQL</h1>\n <span class=\"muted\">Read-only SELECT / WITH queries</span>\n </div>\n </div>\n <div class=\"sql-editor-wrap\">\n <div class=\"sql-toolbar\">\n <div class=\"sql-toolbar-left\">\n <button class=\"btn btn-primary\" id=\"sqlRun\">\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"currentColor\">\n <path d=\"M8 5v14l11-7z\" />\n </svg>\n Run <span class=\"kbd\" style=\"margin-left:4px\">⌘↵</span>\n </button>\n <button class=\"btn\" id=\"sqlFormat\">Format</button>\n <button class=\"btn\" id=\"sqlCopy\" title=\"Copy SQL to clipboard\">Copy</button>\n <button class=\"btn\" id=\"sqlSave\">\n Save <span class=\"kbd\" style=\"margin-left:4px\">⌘S</span>\n </button>\n </div>\n <div class=\"sql-toolbar-left\">\n <span style=\"font-size:10.5px;color:var(--text-muted);font-family:var(--mono);text-transform:uppercase;letter-spacing:0.04em\">\n select / with only\n </span>\n </div>\n </div>\n <textarea\n class=\"sql-editor\"\n id=\"sqlEditor\"\n spellcheck=\"false\"\n placeholder=\"SELECT id, email FROM users ORDER BY created_at DESC LIMIT 10;\"\n ></textarea>\n </div>\n <div class=\"sql-meta\" id=\"sqlMeta\" style=\"display:none\"></div>\n <div class=\"data-wrap\">\n <div class=\"data-scroll\" id=\"sqlResults\"></div>\n </div>\n </section>\n\n <!-- Builder pane -->\n <section class=\"pane\" id=\"pane-builder\" role=\"tabpanel\">\n <div class=\"pane-header\">\n <div class=\"pane-title\">\n <h1>Builder</h1>\n <span class=\"muted\" id=\"builderSub\">Visual findMany composer</span>\n </div>\n <div class=\"toolbar\">\n <button class=\"btn btn-primary\" id=\"builderRun\">\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"currentColor\">\n <path d=\"M8 5v14l11-7z\" />\n </svg>\n Run\n </button>\n <button class=\"btn\" id=\"builderSave\">Save</button>\n <button class=\"btn\" id=\"builderCopy\" title=\"Copy generated TypeScript to clipboard\">Copy TS</button>\n <button class=\"btn btn-ghost btn-sm\" id=\"builderReset\">Reset</button>\n </div>\n </div>\n <div class=\"builder-wrap\">\n <div class=\"builder-left\" id=\"builderLeft\"></div>\n <div class=\"builder-right\">\n <div class=\"builder-preview\" id=\"builderPreview\"></div>\n <div class=\"builder-results\">\n <div class=\"builder-results-header\" id=\"builderResultsHeader\">Results</div>\n <div class=\"data-scroll\" id=\"builderResults\" style=\"flex:1\"></div>\n </div>\n </div>\n </div>\n </section>\n </main>\n </div>\n </div>\n\n <!-- Modals -->\n <div class=\"modal-backdrop\" id=\"savePromptBackdrop\" aria-hidden=\"true\">\n <div class=\"modal\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"savePromptTitle\">\n <div class=\"modal-header\">\n <div class=\"modal-title\" id=\"savePromptTitle\">Save query</div>\n <button class=\"icon-btn\" data-close-modal=\"savePromptBackdrop\" aria-label=\"Close\">×</button>\n </div>\n <div class=\"modal-body\">\n <div class=\"modal-field\">\n <label for=\"savePromptName\">Name</label>\n <input id=\"savePromptName\" type=\"text\" placeholder=\"e.g. recent active users\" />\n </div>\n <div class=\"modal-field\">\n <label for=\"savePromptTable\">Target table</label>\n <select id=\"savePromptTable\"></select>\n </div>\n </div>\n <div class=\"modal-footer\">\n <button class=\"btn btn-ghost\" data-close-modal=\"savePromptBackdrop\">Cancel</button>\n <button class=\"btn btn-primary\" id=\"savePromptConfirm\">Save</button>\n </div>\n </div>\n </div>\n\n <div class=\"modal-backdrop\" id=\"jsonModalBackdrop\" aria-hidden=\"true\">\n <div class=\"modal\" role=\"dialog\" aria-modal=\"true\" style=\"width: 640px\">\n <div class=\"modal-header\">\n <div class=\"modal-title\">JSON value</div>\n <button class=\"icon-btn\" data-close-modal=\"jsonModalBackdrop\" aria-label=\"Close\">×</button>\n </div>\n <div class=\"modal-body\">\n <div class=\"json-view\" id=\"jsonModalBody\"></div>\n </div>\n </div>\n </div>\n\n <div class=\"modal-backdrop\" id=\"paletteBackdrop\" aria-hidden=\"true\">\n <div class=\"modal palette\" role=\"dialog\" aria-modal=\"true\">\n <input\n class=\"palette-input\"\n id=\"paletteInput\"\n type=\"text\"\n placeholder=\"Jump to table, run command…\"\n aria-label=\"Command palette input\"\n />\n <div class=\"palette-list\" id=\"paletteList\"></div>\n </div>\n </div>\n\n <div class=\"toast-wrap\" id=\"toastWrap\" aria-live=\"polite\"></div>\n\n <script>\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 // data pane\n data: {\n rows: [],\n columns: [],\n total: 0,\n limit: 50,\n offset: 0,\n orderBy: null,\n dir: 'asc',\n search: '',\n loading: false,\n error: null,\n },\n // sql pane\n sql: {\n text: '',\n result: null, // { columns, rows, rowCount, elapsedMs }\n error: null,\n running: false,\n },\n // builder pane\n builder: {\n table: null,\n args: emptyBuilderArgs(),\n result: null,\n error: null,\n running: false,\n validation: {},\n },\n savedQueries: [],\n sidebarFilter: '',\n // save prompt context\n savePrompt: {\n kind: null, // 'sql' | 'builder'\n sql: null,\n args: null,\n },\n };\n\n function emptyBuilderArgs() {\n return {\n combinator: 'AND',\n where: [],\n with: [], // [{ relation, args: {...} }]\n orderBy: { column: null, dir: 'asc' },\n limit: 10,\n select: [],\n omit: [],\n };\n }\n\n // =====================================================================\n // Storage (localStorage wrapper)\n // =====================================================================\n const Storage = {\n PREFIX: 'turbine_studio_',\n get(key, fallback) {\n try {\n const raw = localStorage.getItem(Storage.PREFIX + key);\n if (raw == null) return fallback;\n return JSON.parse(raw);\n } catch {\n return fallback;\n }\n },\n set(key, val) {\n try {\n localStorage.setItem(Storage.PREFIX + key, JSON.stringify(val));\n } catch {\n /* ignore quota */\n }\n },\n del(key) {\n try {\n localStorage.removeItem(Storage.PREFIX + key);\n } catch {\n /* ignore */\n }\n },\n };\n\n // =====================================================================\n // API client\n // =====================================================================\n const Api = {\n async _fetch(path, opts = {}) {\n const res = await fetch(path, {\n credentials: 'same-origin',\n headers: { 'Content-Type': 'application/json' },\n ...opts,\n });\n let body = null;\n try {\n body = await res.json();\n } catch {\n /* non-JSON response */\n }\n if (!res.ok) {\n const err = new Error((body && body.error) || res.statusText || 'Request failed');\n err.status = res.status;\n throw err;\n }\n return body;\n },\n schema() {\n return Api._fetch('/api/schema');\n },\n rows(table, params) {\n const qs = new URLSearchParams();\n if (params) {\n for (const [k, v] of Object.entries(params)) {\n if (v !== undefined && v !== null && v !== '') qs.set(k, String(v));\n }\n }\n const q = qs.toString();\n return Api._fetch('/api/tables/' + encodeURIComponent(table) + (q ? '?' + q : ''));\n },\n runSql(sql) {\n return Api._fetch('/api/query', {\n method: 'POST',\n body: JSON.stringify({ sql }),\n });\n },\n runBuilder(table, args) {\n return Api._fetch('/api/builder', {\n method: 'POST',\n body: JSON.stringify({ table, args }),\n });\n },\n savedQueries(table) {\n const qs = table ? '?table=' + encodeURIComponent(table) : '';\n return Api._fetch('/api/saved-queries' + qs);\n },\n saveQuery(payload) {\n return Api._fetch('/api/saved-queries', {\n method: 'POST',\n body: JSON.stringify(payload),\n });\n },\n deleteSaved(id) {\n return Api._fetch('/api/saved-queries/' + encodeURIComponent(id), {\n method: 'DELETE',\n });\n },\n };\n\n // =====================================================================\n // Utilities\n // =====================================================================\n const $ = (sel, root) => (root || document).querySelector(sel);\n const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel));\n\n function el(tag, attrs = {}, children = []) {\n const node = document.createElement(tag);\n for (const [k, v] of Object.entries(attrs)) {\n if (k === 'class') node.className = v;\n else if (k === 'text') node.textContent = v;\n else if (k === 'html') node.innerHTML = v;\n else if (k.startsWith('on') && typeof v === 'function') {\n node.addEventListener(k.slice(2).toLowerCase(), v);\n } else if (k === 'style' && typeof v === 'object') {\n Object.assign(node.style, v);\n } else if (v !== undefined && v !== null && v !== false) {\n node.setAttribute(k, v === true ? '' : String(v));\n }\n }\n const list = Array.isArray(children) ? children : [children];\n for (const c of list) {\n if (c == null || c === false) continue;\n if (typeof c === 'string' || typeof c === 'number') {\n node.appendChild(document.createTextNode(String(c)));\n } else {\n node.appendChild(c);\n }\n }\n return node;\n }\n\n function escapeHtml(s) {\n if (s == null) return '';\n return String(s)\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n }\n\n function formatCount(n) {\n if (n == null) return '—';\n if (n < 1000) return String(n);\n if (n < 1_000_000) return (n / 1000).toFixed(n < 10_000 ? 1 : 0).replace(/\\.0$/, '') + 'k';\n if (n < 1_000_000_000) return (n / 1_000_000).toFixed(n < 10_000_000 ? 1 : 0).replace(/\\.0$/, '') + 'M';\n return (n / 1_000_000_000).toFixed(1).replace(/\\.0$/, '') + 'B';\n }\n\n function formatNumber(n) {\n if (n == null) return '—';\n return Number(n).toLocaleString();\n }\n\n function debounce(fn, ms) {\n let t;\n return (...args) => {\n clearTimeout(t);\n t = setTimeout(() => fn(...args), ms);\n };\n }\n\n function isTypingInInput() {\n const a = document.activeElement;\n if (!a) return false;\n const tag = a.tagName;\n return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || a.isContentEditable;\n }\n\n function isMac() {\n return navigator.platform.toLowerCase().includes('mac');\n }\n\n function toast(msg, kind) {\n const t = el('div', { class: 'toast ' + (kind || ''), text: msg });\n $('#toastWrap').appendChild(t);\n setTimeout(() => {\n t.style.transition = 'opacity 200ms';\n t.style.opacity = '0';\n setTimeout(() => t.remove(), 220);\n }, 2400);\n }\n\n async function copyToClipboard(text, label) {\n if (!text) {\n toast('Nothing to copy', 'err');\n return;\n }\n try {\n if (navigator.clipboard && navigator.clipboard.writeText) {\n await navigator.clipboard.writeText(text);\n } else {\n // Fallback for non-secure contexts / older browsers\n const ta = document.createElement('textarea');\n ta.value = text;\n ta.style.position = 'fixed';\n ta.style.left = '-9999px';\n document.body.appendChild(ta);\n ta.select();\n document.execCommand('copy');\n document.body.removeChild(ta);\n }\n toast((label || 'Copied') + ' to clipboard', 'ok');\n } catch (err) {\n toast('Copy failed: ' + (err && err.message ? err.message : 'unknown'), 'err');\n }\n }\n\n function isStringType(ts) {\n return ts === 'string';\n }\n function isNumericType(ts) {\n return ts === 'number' || ts === 'bigint';\n }\n function isBoolType(ts) {\n return ts === 'boolean';\n }\n function isDateType(ts) {\n return ts === 'Date';\n }\n\n function operatorsForType(ts) {\n const base = ['equals', 'not', 'in', 'notIn'];\n if (isStringType(ts)) return base.concat(['contains', 'startsWith', 'endsWith']);\n if (isNumericType(ts) || isDateType(ts)) return base.concat(['lt', 'lte', 'gt', 'gte']);\n return base;\n }\n\n // =====================================================================\n // Sidebar\n // =====================================================================\n const Sidebar = {\n render() {\n const tablesList = $('#tablesList');\n const enumsList = $('#enumsList');\n tablesList.innerHTML = '';\n enumsList.innerHTML = '';\n\n const filter = State.sidebarFilter.toLowerCase();\n const filtered = State.tables.filter((t) =>\n !filter || t.name.toLowerCase().includes(filter)\n );\n\n if (!filtered.length) {\n tablesList.appendChild(el('div', { class: 'sidebar-empty', text: 'No tables' }));\n } else {\n for (const t of filtered) {\n const active = State.currentTable && State.currentTable.name === t.name;\n const item = el(\n 'div',\n {\n class: 'sidebar-item' + (active ? ' active' : ''),\n role: 'button',\n tabindex: '0',\n 'aria-label': 'Table ' + t.name,\n onclick: () => Router.selectTable(t.name),\n onkeydown: (e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n Router.selectTable(t.name);\n }\n },\n },\n [\n el('span', { class: 'sidebar-item-name', text: t.name }),\n el('span', { class: 'sidebar-item-count', text: formatCount(t.estimatedRows) }),\n ]\n );\n tablesList.appendChild(item);\n }\n }\n\n const enumEntries = Object.entries(State.enums || {});\n if (!enumEntries.length) {\n enumsList.appendChild(el('div', { class: 'sidebar-empty', text: 'No enums' }));\n } else {\n for (const [name, values] of enumEntries) {\n enumsList.appendChild(\n el(\n 'div',\n {\n class: 'sidebar-item',\n title: (values || []).join(', '),\n },\n [\n el('span', { class: 'sidebar-item-name', text: name }),\n el('span', { class: 'sidebar-item-count', text: String((values || []).length) }),\n ]\n )\n );\n }\n }\n\n Sidebar.renderSaved();\n },\n\n renderSaved() {\n const host = $('#savedList');\n host.innerHTML = '';\n if (!State.savedQueries.length) {\n host.appendChild(el('div', { class: 'sidebar-empty', text: 'No saved queries' }));\n return;\n }\n const byTable = new Map();\n for (const q of State.savedQueries) {\n if (!byTable.has(q.table)) byTable.set(q.table, []);\n byTable.get(q.table).push(q);\n }\n const ordered = Array.from(byTable.keys()).sort();\n for (const table of ordered) {\n host.appendChild(el('div', { class: 'saved-table-header', text: table }));\n for (const q of byTable.get(table)) {\n const row = el('div', { class: 'saved-query', onclick: () => SavedQueries.load(q) }, [\n el('span', { class: 'saved-query-name', text: q.name, title: q.name }),\n el('span', { class: 'saved-kind', text: q.kind }),\n el('button', {\n class: 'saved-query-del icon-btn',\n 'aria-label': 'Delete saved query',\n onclick: (e) => {\n e.stopPropagation();\n SavedQueries.remove(q.id);\n },\n text: '×',\n }),\n ]);\n host.appendChild(row);\n }\n }\n },\n\n bind() {\n $('#sidebarSearch').addEventListener('input', (e) => {\n State.sidebarFilter = e.target.value;\n Sidebar.render();\n });\n $$('.sidebar-group-header').forEach((h) => {\n h.addEventListener('click', () => {\n const g = document.getElementById(h.dataset.group);\n if (g) g.classList.toggle('collapsed');\n });\n });\n\n // Resizer\n const resizer = $('#sidebarResizer');\n const sidebar = $('#sidebar');\n let startX = 0;\n let startW = 0;\n const onMove = (e) => {\n const dx = e.clientX - startX;\n const next = Math.min(480, Math.max(200, startW + dx));\n document.documentElement.style.setProperty('--sidebar-w', next + 'px');\n };\n const onUp = () => {\n resizer.classList.remove('dragging');\n document.removeEventListener('mousemove', onMove);\n document.removeEventListener('mouseup', onUp);\n document.body.style.userSelect = '';\n const cur = getComputedStyle(document.documentElement).getPropertyValue('--sidebar-w');\n Storage.set('sidebarW', cur.trim());\n };\n resizer.addEventListener('mousedown', (e) => {\n startX = e.clientX;\n startW = sidebar.getBoundingClientRect().width;\n resizer.classList.add('dragging');\n document.body.style.userSelect = 'none';\n document.addEventListener('mousemove', onMove);\n document.addEventListener('mouseup', onUp);\n });\n\n $('#hamburger').addEventListener('click', () => {\n sidebar.classList.toggle('open');\n });\n },\n };\n\n // =====================================================================\n // Router / tabs\n // =====================================================================\n const Router = {\n selectTab(tab) {\n State.currentTab = tab;\n Storage.set('tab', tab);\n $$('.tab').forEach((b) =>\n b.classList.toggle('active', b.dataset.tab === tab)\n );\n $$('.pane').forEach((p) =>\n p.classList.toggle('active', p.id === 'pane-' + tab)\n );\n if (tab === 'schema') SchemaPane.render();\n if (tab === 'data') DataPane.render();\n if (tab === 'sql') SqlPane.focus();\n if (tab === 'builder') BuilderPane.render();\n },\n\n selectTable(name) {\n const t = State.tablesByName.get(name);\n if (!t) return;\n State.currentTable = t;\n State.data.offset = 0;\n State.data.orderBy = (t.primaryKey && t.primaryKey[0]) || (t.columns[0] && t.columns[0].name);\n State.data.dir = 'asc';\n State.data.search = '';\n Storage.set('currentTable', name);\n Sidebar.render();\n if (State.currentTab === 'data') DataPane.loadAndRender();\n if (State.currentTab === 'schema') SchemaPane.render();\n if (State.currentTab === 'builder') {\n BuilderPane.resetForTable(name);\n }\n // always close mobile sidebar after selecting\n $('#sidebar').classList.remove('open');\n },\n\n bind() {\n $$('.tab').forEach((btn) => {\n btn.addEventListener('click', () => Router.selectTab(btn.dataset.tab));\n });\n },\n };\n\n // =====================================================================\n // Data pane\n // =====================================================================\n const DataPane = {\n render() {\n const t = State.currentTable;\n if (!t) {\n $('#dataTitle').textContent = '—';\n $('#dataTotal').textContent = '';\n $('#dataScroll').innerHTML = '<div class=\"empty\">Select a table to browse data.</div>';\n $('#dataRange').textContent = '—';\n $('#pageIndicator').textContent = '—';\n return;\n }\n $('#dataTitle').textContent = t.name;\n $('#dataTotal').textContent = State.data.loading ? 'loading…' : formatNumber(State.data.total) + ' rows';\n\n const host = $('#dataScroll');\n\n if (State.data.error) {\n host.innerHTML = '';\n host.appendChild(\n el('div', { class: 'error-box' }, [\n el('div', { class: 'error-box-title', text: 'Error loading data' }),\n document.createTextNode(State.data.error),\n ])\n );\n $('#dataRange').textContent = '—';\n $('#pageIndicator').textContent = '—';\n return;\n }\n\n if (State.data.loading) {\n host.innerHTML = '';\n const skel = el('div');\n for (let i = 0; i < 12; i++) {\n const row = el('div', { class: 'skeleton-row' });\n for (let c = 0; c < 5; c++) {\n row.appendChild(el('div', { class: 'skeleton-cell' }));\n }\n skel.appendChild(row);\n }\n host.appendChild(skel);\n return;\n }\n\n if (!State.data.rows.length) {\n host.innerHTML = '';\n host.appendChild(\n el('div', { class: 'empty' }, [\n el(\n 'svg',\n {\n class: 'empty-icon',\n viewBox: '0 0 24 24',\n fill: 'none',\n stroke: 'currentColor',\n 'stroke-width': '2',\n },\n []\n ),\n document.createTextNode(\n State.data.search ? 'No rows match your search.' : 'No rows.'\n ),\n ])\n );\n $('#dataRange').textContent = '0 of 0';\n $('#pageIndicator').textContent = '—';\n return;\n }\n\n host.innerHTML = '';\n const cols = State.data.columns.length\n ? State.data.columns.map((c) => c.name)\n : Object.keys(State.data.rows[0] || {});\n host.appendChild(Table.render(cols, State.data.rows, {\n sortable: true,\n orderBy: State.data.orderBy,\n dir: State.data.dir,\n onSort: (col) => {\n if (State.data.orderBy === col) {\n State.data.dir = State.data.dir === 'asc' ? 'desc' : 'asc';\n } else {\n State.data.orderBy = col;\n State.data.dir = 'asc';\n }\n State.data.offset = 0;\n DataPane.loadAndRender();\n },\n }));\n\n const start = State.data.total ? State.data.offset + 1 : 0;\n const end = Math.min(State.data.offset + State.data.limit, State.data.total);\n $('#dataRange').textContent = start + '-' + end + ' of ' + formatNumber(State.data.total);\n const page = Math.floor(State.data.offset / State.data.limit) + 1;\n const pages = Math.max(1, Math.ceil(State.data.total / State.data.limit));\n $('#pageIndicator').textContent = 'Page ' + page + ' / ' + pages;\n $('#pagePrev').disabled = State.data.offset === 0;\n $('#pageNext').disabled = State.data.offset + State.data.limit >= State.data.total;\n },\n\n async loadAndRender() {\n const t = State.currentTable;\n if (!t) return;\n State.data.loading = true;\n State.data.error = null;\n DataPane.render();\n try {\n const params = {\n limit: State.data.limit,\n offset: State.data.offset,\n orderBy: State.data.orderBy,\n dir: State.data.dir,\n search: State.data.search,\n };\n const res = await Api.rows(t.name, params);\n State.data.rows = res.rows || [];\n State.data.columns = res.columns || [];\n State.data.total = res.total || 0;\n } catch (err) {\n State.data.error = err.message;\n State.data.rows = [];\n State.data.total = 0;\n } finally {\n State.data.loading = false;\n DataPane.render();\n }\n },\n\n bind() {\n $('#dataSearch').addEventListener(\n 'input',\n debounce((e) => {\n State.data.search = e.target.value;\n State.data.offset = 0;\n DataPane.loadAndRender();\n }, 240)\n );\n $('#dataRefresh').addEventListener('click', () => DataPane.loadAndRender());\n $('#pagePrev').addEventListener('click', () => {\n if (State.data.offset === 0) return;\n State.data.offset = Math.max(0, State.data.offset - State.data.limit);\n DataPane.loadAndRender();\n });\n $('#pageNext').addEventListener('click', () => {\n if (State.data.offset + State.data.limit >= State.data.total) return;\n State.data.offset += State.data.limit;\n DataPane.loadAndRender();\n });\n },\n };\n\n // =====================================================================\n // Shared data table renderer\n // =====================================================================\n const Table = {\n render(cols, rows, opts = {}) {\n const table = el('table', { class: 'data' });\n const thead = el('thead');\n const headRow = el('tr');\n for (const c of cols) {\n const attrs = {\n class: opts.sortable ? 'sortable' : '',\n scope: 'col',\n };\n if (opts.sortable) {\n attrs.onclick = () => opts.onSort && opts.onSort(c);\n }\n const th = el('th', attrs);\n th.appendChild(document.createTextNode(c));\n if (opts.sortable && opts.orderBy === c) {\n th.appendChild(el('span', { class: 'sort-ind', text: opts.dir === 'asc' ? '▲' : '▼' }));\n }\n headRow.appendChild(th);\n }\n thead.appendChild(headRow);\n table.appendChild(thead);\n\n const tbody = el('tbody');\n for (const row of rows) {\n const tr = el('tr');\n for (const c of cols) {\n const val = row[c];\n tr.appendChild(Table.renderCell(val));\n }\n tbody.appendChild(tr);\n }\n table.appendChild(tbody);\n return table;\n },\n\n renderCell(val) {\n const td = el('td');\n if (val === null || val === undefined) {\n td.className = 'cell-null';\n td.textContent = 'null';\n td.title = 'null';\n return td;\n }\n if (typeof val === 'number') {\n td.className = 'cell-number';\n td.textContent = String(val);\n td.title = String(val);\n return td;\n }\n if (typeof val === 'boolean') {\n td.className = 'cell-bool';\n td.textContent = String(val);\n td.title = String(val);\n return td;\n }\n if (val && typeof val === 'object') {\n td.className = 'cell-json';\n const str = JSON.stringify(val);\n const preview = str.length > 48 ? str.slice(0, 45) + '…' : str;\n td.textContent = preview;\n td.title = 'JSON (click to expand)';\n const btn = el('button', {\n class: 'expand-btn',\n text: 'expand',\n onclick: (e) => {\n e.stopPropagation();\n JsonModal.open(val);\n },\n });\n td.appendChild(btn);\n return td;\n }\n const s = String(val);\n td.textContent = s;\n td.title = s;\n return td;\n },\n };\n\n // =====================================================================\n // Schema pane\n // =====================================================================\n const SchemaPane = {\n render() {\n const t = State.currentTable;\n const host = $('#schemaScroll');\n if (!t) {\n $('#schemaTitle').textContent = '—';\n $('#schemaSub').textContent = '';\n host.innerHTML = '<div class=\"empty\">Select a table to inspect its schema.</div>';\n return;\n }\n $('#schemaTitle').textContent = t.name;\n $('#schemaSub').textContent =\n (t.columns.length + ' columns · ' + (t.relations || []).length + ' relations');\n host.innerHTML = '';\n\n // Columns\n const colsSection = el('section', { class: 'schema-section' });\n colsSection.appendChild(el('h2', { text: 'Columns' }));\n const colTable = el('table', { class: 'col-table' });\n const colHead = el('thead');\n colHead.appendChild(\n el('tr', {}, ['Name', 'TS', 'PG', 'Attributes'].map((h) => el('th', { text: h })))\n );\n colTable.appendChild(colHead);\n const colBody = el('tbody');\n for (const c of t.columns) {\n const tr = el('tr');\n tr.appendChild(\n el('td', {}, [\n el('span', {\n class: 'col-name' + (c.isPrimaryKey ? ' pk' : ''),\n text: c.name,\n }),\n ])\n );\n tr.appendChild(el('td', {}, [el('span', { class: 'col-ts', text: c.tsType || '—' })]));\n tr.appendChild(el('td', {}, [el('span', { class: 'col-pg', text: c.pgType || '—' })]));\n const attrs = el('td');\n if (c.isPrimaryKey) attrs.appendChild(el('span', { class: 'badge pk', text: 'PK' }));\n if (!c.nullable) attrs.appendChild(el('span', { class: 'badge nn', text: 'NOT NULL' }));\n else attrs.appendChild(el('span', { class: 'badge nullable', text: 'nullable' }));\n if (c.hasDefault) attrs.appendChild(el('span', { class: 'badge def', text: 'default' }));\n 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 // SQL pane\n // =====================================================================\n const SqlPane = {\n focus() {\n const ed = $('#sqlEditor');\n if (ed && !ed.value) {\n ed.value = Storage.get('sqlDraft', '') || '';\n }\n setTimeout(() => ed.focus(), 30);\n },\n\n bind() {\n const ed = $('#sqlEditor');\n ed.addEventListener('input', () => {\n State.sql.text = ed.value;\n Storage.set('sqlDraft', ed.value);\n });\n ed.addEventListener('keydown', (e) => {\n if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {\n e.preventDefault();\n SqlPane.run();\n }\n if ((e.metaKey || e.ctrlKey) && (e.key === 's' || e.key === 'S')) {\n e.preventDefault();\n SqlPane.openSave();\n }\n if (e.key === 'Tab') {\n e.preventDefault();\n const start = ed.selectionStart;\n const end = ed.selectionEnd;\n ed.value = ed.value.slice(0, start) + ' ' + ed.value.slice(end);\n ed.selectionStart = ed.selectionEnd = start + 2;\n }\n });\n $('#sqlRun').addEventListener('click', () => SqlPane.run());\n $('#sqlFormat').addEventListener('click', () => {\n ed.value = SqlPane.format(ed.value);\n State.sql.text = ed.value;\n Storage.set('sqlDraft', ed.value);\n });\n $('#sqlCopy').addEventListener('click', () => {\n copyToClipboard($('#sqlEditor').value, 'SQL copied');\n });\n $('#sqlSave').addEventListener('click', () => SqlPane.openSave());\n },\n\n async run() {\n const text = $('#sqlEditor').value.trim();\n if (!text) return;\n State.sql.running = true;\n State.sql.error = null;\n SqlPane.renderResults();\n try {\n const res = await Api.runSql(text);\n State.sql.result = res;\n State.sql.error = null;\n } catch (err) {\n State.sql.error = err.message;\n State.sql.result = null;\n } finally {\n State.sql.running = false;\n SqlPane.renderResults();\n }\n },\n\n renderResults() {\n const meta = $('#sqlMeta');\n const host = $('#sqlResults');\n if (State.sql.running) {\n meta.style.display = 'none';\n host.innerHTML = '';\n const skel = el('div');\n for (let i = 0; i < 8; 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.sql.error) {\n meta.style.display = 'none';\n host.innerHTML = '';\n host.appendChild(\n el('div', { class: 'error-box' }, [\n el('div', { class: 'error-box-title', text: 'Query error' }),\n document.createTextNode(State.sql.error),\n ])\n );\n return;\n }\n const r = State.sql.result;\n if (!r) {\n meta.style.display = 'none';\n host.innerHTML = '<div class=\"empty\">Run a query to see results.</div>';\n return;\n }\n meta.style.display = '';\n meta.innerHTML = '';\n meta.appendChild(el('span', { class: 'ok', text: '●' }));\n meta.appendChild(document.createTextNode(' ' + formatNumber(r.rowCount) + ' rows'));\n meta.appendChild(el('span', { class: 'sep', text: '·' }));\n meta.appendChild(document.createTextNode(r.elapsedMs + ' ms'));\n meta.appendChild(el('span', { class: 'sep', text: '·' }));\n meta.appendChild(document.createTextNode((r.columns || []).length + ' columns'));\n\n host.innerHTML = '';\n if (!r.rows || !r.rows.length) {\n host.appendChild(el('div', { class: 'empty', text: 'Query returned no rows.' }));\n return;\n }\n const cols = (r.columns || []).map((c) => c.name || c);\n host.appendChild(Table.render(cols, r.rows));\n },\n\n format(sql) {\n // Minimal client-side formatter: uppercase keywords + newlines before clauses\n const kws = [\n 'SELECT',\n 'FROM',\n 'WHERE',\n 'ORDER BY',\n 'GROUP BY',\n 'HAVING',\n 'LIMIT',\n 'OFFSET',\n 'JOIN',\n 'LEFT JOIN',\n 'RIGHT JOIN',\n 'INNER JOIN',\n 'OUTER JOIN',\n 'ON',\n 'AND',\n 'OR',\n 'WITH',\n 'UNION',\n 'UNION ALL',\n 'INTERSECT',\n 'EXCEPT',\n 'AS',\n ];\n let out = sql.replace(/\\s+/g, ' ').trim();\n const patterns = kws\n .slice()\n .sort((a, b) => b.length - a.length)\n .map((k) => ({\n k,\n re: new RegExp('\\\\b' + k.replace(/ /g, '\\\\s+') + '\\\\b', 'gi'),\n }));\n for (const { k, re } of patterns) {\n out = out.replace(re, k);\n }\n const breakBefore = ['FROM', 'WHERE', 'ORDER BY', 'GROUP BY', 'HAVING', 'LIMIT', 'OFFSET'];\n for (const k of breakBefore) {\n out = out.replace(new RegExp('\\\\s*' + k + '\\\\s*', 'g'), '\\n' + k + ' ');\n }\n out = out.replace(/\\s*,\\s*/g, ', ');\n return out.trim();\n },\n\n openSave() {\n const text = $('#sqlEditor').value.trim();\n if (!text) return;\n State.savePrompt = { kind: 'sql', sql: text, args: null };\n Modal.open('savePromptBackdrop');\n $('#savePromptName').value = '';\n SavePrompt.fillTables();\n },\n };\n\n // =====================================================================\n // Builder pane\n // =====================================================================\n const BuilderPane = {\n resetForTable(tableName) {\n State.builder.table = tableName;\n State.builder.args = emptyBuilderArgs();\n State.builder.result = null;\n State.builder.error = null;\n State.builder.validation = {};\n BuilderPane.render();\n },\n\n findTable(name) {\n return State.tablesByName.get(name);\n },\n\n render() {\n const host = $('#builderLeft');\n host.innerHTML = '';\n if (!State.tables.length) {\n host.appendChild(el('div', { class: 'empty', text: 'Loading schema…' }));\n BuilderPane.updatePreview();\n return;\n }\n if (!State.builder.table) {\n State.builder.table =\n (State.currentTable && State.currentTable.name) ||\n (State.tables[0] && State.tables[0].name);\n }\n $('#builderSub').textContent = 'Visual findMany composer';\n\n // Table picker\n const tablePick = el('div', { class: 'builder-section' }, [\n el('div', { class: 'builder-section-title' }, [el('h3', { text: 'Table' })]),\n (() => {\n const sel = el('select', { class: 'builder-select', id: 'builderTable' });\n for (const t of State.tables) {\n sel.appendChild(el('option', { value: t.name, text: t.name }));\n }\n sel.value = State.builder.table;\n sel.addEventListener('change', () => {\n BuilderPane.resetForTable(sel.value);\n });\n return sel;\n })(),\n ]);\n host.appendChild(tablePick);\n\n const tableMeta = BuilderPane.findTable(State.builder.table);\n if (!tableMeta) {\n host.appendChild(el('div', { class: 'error-box', text: 'Unknown table.' }));\n return;\n }\n\n // WHERE\n host.appendChild(BuilderPane.renderWhere(State.builder.args, tableMeta, []));\n // WITH\n host.appendChild(BuilderPane.renderWith(State.builder.args, tableMeta, []));\n // ORDER BY / LIMIT\n host.appendChild(BuilderPane.renderOrderLimit(State.builder.args, tableMeta));\n // SELECT / OMIT\n host.appendChild(BuilderPane.renderSelectOmit(State.builder.args, tableMeta));\n\n BuilderPane.updatePreview();\n BuilderPane.updateRunButton();\n },\n\n renderWhere(args, tableMeta, path) {\n const section = el('div', { class: 'builder-section' });\n const header = el('div', { class: 'builder-section-title' }, [\n el('h3', { text: 'where' }),\n (() => {\n const pick = el('div', { class: 'combinator-pick' });\n for (const c of ['AND', 'OR', 'NOT']) {\n pick.appendChild(\n el('button', {\n class: args.combinator === c ? 'active' : '',\n text: c,\n onclick: () => {\n args.combinator = c;\n BuilderPane.render();\n },\n })\n );\n }\n return pick;\n })(),\n ]);\n section.appendChild(header);\n\n const wrap = el('div', { class: 'where-clauses' });\n args.where.forEach((clause, idx) => {\n wrap.appendChild(BuilderPane.renderClause(args, clause, idx, tableMeta));\n });\n section.appendChild(wrap);\n\n const addRow = el('div', { class: 'clause-row', style: { marginTop: '8px' } }, [\n el('button', {\n class: 'btn btn-sm',\n text: '+ Clause',\n onclick: () => {\n args.where.push({\n column: (tableMeta.columns[0] || {}).name || '',\n op: 'equals',\n value: '',\n insensitive: false,\n });\n BuilderPane.render();\n },\n }),\n ]);\n section.appendChild(addRow);\n\n return section;\n },\n\n renderClause(args, clause, idx, tableMeta) {\n const row = el('div', { class: 'where-clause' });\n // column select\n const colSel = el('select', { class: 'builder-select' });\n for (const c of tableMeta.columns) {\n const opt = el('option', { value: c.name, text: c.name });\n if (c.name === clause.column) opt.selected = true;\n colSel.appendChild(opt);\n }\n colSel.addEventListener('change', () => {\n clause.column = colSel.value;\n const col = tableMeta.columns.find((c) => c.name === clause.column);\n const ops = col ? operatorsForType(col.tsType) : ['equals'];\n if (!ops.includes(clause.op)) clause.op = ops[0];\n BuilderPane.render();\n });\n row.appendChild(colSel);\n\n // op select\n const col = tableMeta.columns.find((c) => c.name === clause.column);\n const ops = col ? operatorsForType(col.tsType) : ['equals'];\n const opSel = el('select', { class: 'builder-select' });\n for (const op of ops) {\n const opt = el('option', { value: op, text: op });\n if (op === clause.op) opt.selected = true;\n opSel.appendChild(opt);\n }\n opSel.addEventListener('change', () => {\n clause.op = opSel.value;\n BuilderPane.render();\n });\n row.appendChild(opSel);\n\n // value input\n const valInput = el('input', {\n class: 'builder-input',\n type: col && isNumericType(col.tsType) ? 'number' : 'text',\n placeholder: 'value',\n value: clause.value != null ? String(clause.value) : '',\n });\n valInput.addEventListener('input', () => {\n clause.value = valInput.value;\n BuilderPane.updatePreview();\n BuilderPane.updateRunButton();\n });\n row.appendChild(valInput);\n\n // remove\n const rm = el('button', {\n class: 'btn-ghost icon-btn',\n 'aria-label': 'Remove clause',\n text: '×',\n onclick: () => {\n args.where.splice(idx, 1);\n BuilderPane.render();\n },\n });\n row.appendChild(rm);\n\n // insensitive toggle for string cols\n if (col && isStringType(col.tsType) && ['contains', 'startsWith', 'endsWith', 'equals'].includes(clause.op)) {\n const ci = el('div', { class: 'clause-row', style: { gridColumn: '1 / -1' } }, [\n el(\n 'button',\n {\n class: 'chip' + (clause.insensitive ? ' active' : ''),\n text: 'mode: insensitive',\n onclick: () => {\n clause.insensitive = !clause.insensitive;\n BuilderPane.render();\n },\n },\n []\n ),\n ]);\n const both = el('div', { class: 'builder-field' });\n both.appendChild(row);\n both.appendChild(ci);\n return both;\n }\n return row;\n },\n\n renderWith(args, tableMeta, path) {\n const section = el('div', { class: 'builder-section' });\n section.appendChild(\n el('div', { class: 'builder-section-title' }, [el('h3', { text: 'with (relations)' })])\n );\n const rels = tableMeta.relations || [];\n 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 body.appendChild(BuilderPane.renderWhere(existing.args, targetMeta, path.concat(rel.name)));\n body.appendChild(BuilderPane.renderOrderLimit(existing.args, targetMeta));\n body.appendChild(BuilderPane.renderWith(existing.args, targetMeta, path.concat(rel.name)));\n }\n relBox.appendChild(body);\n }\n wrap.appendChild(relBox);\n }\n section.appendChild(wrap);\n return section;\n },\n\n renderOrderLimit(args, tableMeta) {\n const section = el('div', { class: 'builder-section' });\n section.appendChild(\n el('div', { class: 'builder-section-title' }, [el('h3', { text: 'orderBy / limit' })])\n );\n\n const row = el('div', { style: { display: 'grid', gridTemplateColumns: '1fr 120px 100px', gap: '8px' } });\n\n const colSel = el('select', { class: 'builder-select' });\n colSel.appendChild(el('option', { value: '', text: '— none —' }));\n for (const c of tableMeta.columns) {\n const opt = el('option', { value: c.name, text: c.name });\n if (args.orderBy && args.orderBy.column === c.name) opt.selected = true;\n colSel.appendChild(opt);\n }\n colSel.addEventListener('change', () => {\n args.orderBy = args.orderBy || { column: null, dir: 'asc' };\n args.orderBy.column = colSel.value || null;\n BuilderPane.updatePreview();\n });\n row.appendChild(colSel);\n\n const dirSel = el('select', { class: 'builder-select' });\n for (const d of ['asc', 'desc']) {\n const opt = el('option', { value: d, text: d });\n if (args.orderBy && args.orderBy.dir === d) opt.selected = true;\n dirSel.appendChild(opt);\n }\n dirSel.addEventListener('change', () => {\n args.orderBy = args.orderBy || { column: null, dir: 'asc' };\n args.orderBy.dir = dirSel.value;\n BuilderPane.updatePreview();\n });\n row.appendChild(dirSel);\n\n const limInput = el('input', {\n class: 'builder-input',\n type: 'number',\n min: '1',\n max: '10000',\n value: args.limit != null ? String(args.limit) : '',\n placeholder: 'limit',\n });\n limInput.addEventListener('input', () => {\n const n = parseInt(limInput.value, 10);\n args.limit = Number.isFinite(n) && n > 0 ? n : null;\n BuilderPane.updatePreview();\n BuilderPane.updateRunButton();\n });\n row.appendChild(limInput);\n section.appendChild(row);\n return section;\n },\n\n renderSelectOmit(args, tableMeta) {\n const section = el('div', { class: 'builder-section' });\n section.appendChild(\n el('div', { class: 'builder-section-title' }, [el('h3', { text: 'select / omit' })])\n );\n const chipsSel = el('div', { class: 'chip-row' });\n chipsSel.appendChild(\n el(\n 'span',\n { style: { fontFamily: 'var(--mono)', fontSize: '10px', color: 'var(--text-muted)', marginRight: '4px' } },\n ['select:']\n )\n );\n for (const c of tableMeta.columns) {\n const active = args.select.includes(c.name);\n chipsSel.appendChild(\n el('button', {\n class: 'chip' + (active ? ' active' : ''),\n text: c.name,\n onclick: () => {\n if (active) args.select = args.select.filter((x) => x !== c.name);\n else {\n args.select.push(c.name);\n args.omit = args.omit.filter((x) => x !== c.name);\n }\n BuilderPane.render();\n },\n })\n );\n }\n section.appendChild(chipsSel);\n\n const chipsOm = el('div', { class: 'chip-row', style: { marginTop: '8px' } });\n chipsOm.appendChild(\n el(\n 'span',\n { style: { fontFamily: 'var(--mono)', fontSize: '10px', color: 'var(--text-muted)', marginRight: '4px' } },\n ['omit:']\n )\n );\n for (const c of tableMeta.columns) {\n const active = args.omit.includes(c.name);\n chipsOm.appendChild(\n el('button', {\n class: 'chip' + (active ? ' active' : ''),\n text: c.name,\n onclick: () => {\n if (active) args.omit = args.omit.filter((x) => x !== c.name);\n else {\n args.omit.push(c.name);\n args.select = args.select.filter((x) => x !== c.name);\n }\n BuilderPane.render();\n },\n })\n );\n }\n section.appendChild(chipsOm);\n return section;\n },\n\n // ---------- Spec assembly / validation ----------\n buildFindManyArgs(localArgs, tableMeta) {\n const out = {};\n const whereObj = BuilderPane.assembleWhere(localArgs, tableMeta);\n if (whereObj) out.where = whereObj;\n if (localArgs.with && localArgs.with.length) {\n out.with = {};\n for (const w of localArgs.with) {\n const rel = (tableMeta.relations || []).find((r) => r.name === w.relation);\n if (!rel) continue;\n const childMeta = BuilderPane.findTable(rel.to);\n const childArgs = childMeta ? BuilderPane.buildFindManyArgs(w.args, childMeta) : null;\n if (childArgs && Object.keys(childArgs).length) {\n out.with[w.relation] = childArgs;\n } else {\n out.with[w.relation] = true;\n }\n }\n }\n if (localArgs.orderBy && localArgs.orderBy.column) {\n out.orderBy = { [localArgs.orderBy.column]: localArgs.orderBy.dir };\n }\n if (localArgs.limit) out.limit = localArgs.limit;\n if (localArgs.select && localArgs.select.length) {\n out.select = Object.fromEntries(localArgs.select.map((c) => [c, true]));\n }\n if (localArgs.omit && localArgs.omit.length) {\n out.omit = Object.fromEntries(localArgs.omit.map((c) => [c, true]));\n }\n return out;\n },\n\n assembleWhere(localArgs, tableMeta) {\n if (!localArgs.where || !localArgs.where.length) return null;\n const clauses = [];\n for (const c of localArgs.where) {\n const col = tableMeta.columns.find((cc) => cc.name === c.column);\n if (!col) continue;\n const parsed = BuilderPane.coerceValue(c.value, col.tsType, c.op);\n if (parsed === undefined) continue;\n const inner =\n c.op === 'equals'\n ? parsed\n : c.insensitive\n ? { [c.op]: parsed, mode: 'insensitive' }\n : { [c.op]: parsed };\n clauses.push({ [c.column]: inner });\n }\n if (!clauses.length) return null;\n if (clauses.length === 1 && localArgs.combinator === 'AND') return clauses[0];\n if (localArgs.combinator === 'AND') return Object.assign({}, ...clauses);\n if (localArgs.combinator === 'OR') return { OR: clauses };\n if (localArgs.combinator === 'NOT') return { NOT: clauses.length === 1 ? clauses[0] : { AND: clauses } };\n return null;\n },\n\n coerceValue(val, tsType, op) {\n if (val == null || val === '') return undefined;\n if (op === 'in' || op === 'notIn') {\n return String(val)\n .split(',')\n .map((s) => s.trim())\n .filter(Boolean)\n .map((s) => (isNumericType(tsType) ? Number(s) : s));\n }\n if (isNumericType(tsType)) {\n const n = Number(val);\n return Number.isFinite(n) ? n : undefined;\n }\n if (isBoolType(tsType)) {\n return val === 'true' || val === true;\n }\n return String(val);\n },\n\n validate() {\n const errs = {};\n if (!State.builder.table) errs.table = 'Pick a table';\n if (State.builder.args.limit != null && State.builder.args.limit < 1) {\n errs.limit = 'Limit must be ≥ 1';\n }\n for (const c of State.builder.args.where) {\n if (!c.column) errs.where = 'Clause missing column';\n if (c.value === '' || c.value == null) errs.where = 'Clause missing value';\n }\n State.builder.validation = errs;\n return Object.keys(errs).length === 0;\n },\n\n updateRunButton() {\n const ok = BuilderPane.validate();\n $('#builderRun').disabled = !ok;\n },\n\n updatePreview() {\n const host = $('#builderPreview');\n const tableMeta = BuilderPane.findTable(State.builder.table);\n if (!tableMeta) {\n host.textContent = '';\n host.dataset.rawCode = '';\n return;\n }\n const args = BuilderPane.buildFindManyArgs(State.builder.args, tableMeta);\n const code = BuilderPane.renderCode(State.builder.table, args);\n host.dataset.rawCode = code;\n host.innerHTML = highlightTs(code);\n },\n\n renderCode(tableName, args) {\n const ind = ' ';\n let out = 'await client.' + tableName + '.findMany(';\n const body = renderJsArgs(args, 1);\n if (!body) {\n out += ');';\n } else {\n out += '{\\n' + body + '\\n});';\n }\n return out;\n },\n\n async run() {\n if (!BuilderPane.validate()) return;\n const tableMeta = BuilderPane.findTable(State.builder.table);\n const args = BuilderPane.buildFindManyArgs(State.builder.args, tableMeta);\n State.builder.running = true;\n State.builder.error = null;\n BuilderPane.renderResults();\n try {\n const res = await Api.runBuilder(State.builder.table, args);\n State.builder.result = res;\n State.builder.error = null;\n } catch (err) {\n State.builder.error = err.message;\n State.builder.result = null;\n } finally {\n State.builder.running = false;\n BuilderPane.renderResults();\n }\n },\n\n renderResults() {\n const host = $('#builderResults');\n const header = $('#builderResultsHeader');\n if (State.builder.running) {\n header.textContent = 'Results · running…';\n host.innerHTML = '';\n const skel = el('div');\n for (let i = 0; i < 6; i++) {\n const row = el('div', { class: 'skeleton-row' });\n for (let c = 0; c < 4; c++) row.appendChild(el('div', { class: 'skeleton-cell' }));\n skel.appendChild(row);\n }\n host.appendChild(skel);\n return;\n }\n if (State.builder.error) {\n header.textContent = 'Results · error';\n host.innerHTML = '';\n host.appendChild(\n el('div', { class: 'error-box' }, [\n el('div', { class: 'error-box-title', text: 'Builder error' }),\n document.createTextNode(State.builder.error),\n ])\n );\n return;\n }\n const r = State.builder.result;\n if (!r) {\n header.textContent = 'Results';\n host.innerHTML = '<div class=\"empty\">Run to see results.</div>';\n return;\n }\n header.textContent = 'Results · ' + formatNumber(r.rowCount) + ' rows · ' + r.elapsedMs + 'ms';\n host.innerHTML = '';\n if (!r.rows || !r.rows.length) {\n host.appendChild(el('div', { class: 'empty', text: 'No rows.' }));\n return;\n }\n const cols = (r.columns || []).map((c) => c.name || c);\n const rendered = cols.length ? cols : Object.keys(r.rows[0] || {});\n host.appendChild(Table.render(rendered, r.rows));\n },\n\n openSave() {\n if (!BuilderPane.validate()) return;\n const tableMeta = BuilderPane.findTable(State.builder.table);\n const args = BuilderPane.buildFindManyArgs(State.builder.args, tableMeta);\n State.savePrompt = { kind: 'builder', sql: null, args, fixedTable: State.builder.table };\n Modal.open('savePromptBackdrop');\n $('#savePromptName').value = '';\n SavePrompt.fillTables(State.builder.table);\n },\n\n bind() {\n $('#builderRun').addEventListener('click', () => BuilderPane.run());\n $('#builderSave').addEventListener('click', () => BuilderPane.openSave());\n $('#builderCopy').addEventListener('click', () => {\n const code = $('#builderPreview').dataset.rawCode || '';\n copyToClipboard(code, 'TypeScript copied');\n });\n $('#builderReset').addEventListener('click', () => {\n if (State.builder.table) BuilderPane.resetForTable(State.builder.table);\n });\n },\n };\n\n // ---------- JS args renderer (used by builder preview) ----------\n function renderJsArgs(obj, depth) {\n if (obj == null) return '';\n const ind = ' '.repeat(depth);\n const indIn = ' '.repeat(depth + 1);\n const keys = Object.keys(obj);\n if (!keys.length) return '';\n const lines = [];\n for (const k of keys) {\n const v = obj[k];\n lines.push(ind + k + ': ' + renderJsValue(v, depth) + ',');\n }\n return lines.join('\\n');\n }\n function renderJsValue(v, depth) {\n if (v === null) return 'null';\n if (v === undefined) return 'undefined';\n if (typeof v === 'boolean') return v ? 'true' : 'false';\n if (typeof v === 'number') return String(v);\n if (typeof v === 'string') return \"'\" + v.replace(/\\\\/g, '\\\\\\\\').replace(/'/g, \"\\\\'\") + \"'\";\n if (Array.isArray(v)) {\n if (!v.length) return '[]';\n return '[' + v.map((x) => renderJsValue(x, depth)).join(', ') + ']';\n }\n if (typeof v === 'object') {\n const keys = Object.keys(v);\n if (!keys.length) return '{}';\n const ind = ' '.repeat(depth + 1);\n const close = ' '.repeat(depth);\n const inner = keys.map((k) => ind + k + ': ' + renderJsValue(v[k], depth + 1)).join(',\\n');\n return '{\\n' + inner + '\\n' + close + '}';\n }\n return String(v);\n }\n\n // ---------- TS syntax highlighter (simple tokenizer) ----------\n function highlightTs(src) {\n const KWS = new Set([\n 'await',\n 'async',\n 'const',\n 'let',\n 'var',\n 'return',\n 'if',\n 'else',\n 'true',\n 'false',\n 'null',\n 'undefined',\n 'import',\n 'from',\n 'export',\n 'new',\n ]);\n const out = [];\n let i = 0;\n while (i < src.length) {\n const ch = src[i];\n // comment\n if (ch === '/' && src[i + 1] === '/') {\n let j = i;\n while (j < src.length && src[j] !== '\\n') j++;\n out.push('<span class=\"tok-comment\">' + escapeHtml(src.slice(i, j)) + '</span>');\n i = j;\n continue;\n }\n // string\n if (ch === \"'\" || ch === '\"' || ch === '`') {\n const q = ch;\n let j = i + 1;\n while (j < src.length && src[j] !== q) {\n if (src[j] === '\\\\') j += 2;\n else j++;\n }\n j = Math.min(src.length, j + 1);\n out.push('<span class=\"tok-str\">' + escapeHtml(src.slice(i, j)) + '</span>');\n i = j;\n continue;\n }\n // number\n if (/[0-9]/.test(ch)) {\n let j = i;\n while (j < src.length && /[0-9_.]/.test(src[j])) j++;\n out.push('<span class=\"tok-num\">' + escapeHtml(src.slice(i, j)) + '</span>');\n i = j;\n continue;\n }\n // identifier / keyword\n if (/[A-Za-z_$]/.test(ch)) {\n let j = i;\n while (j < src.length && /[A-Za-z0-9_$]/.test(src[j])) j++;\n const word = src.slice(i, j);\n // key: identifier followed by colon\n let k = j;\n while (k < src.length && src[k] === ' ') k++;\n if (src[k] === ':') {\n out.push('<span class=\"tok-key\">' + escapeHtml(word) + '</span>');\n } else if (KWS.has(word)) {\n out.push('<span class=\"tok-kw\">' + escapeHtml(word) + '</span>');\n } else if (src[j] === '(' || (src[j] === '.' && false)) {\n out.push('<span class=\"tok-fn\">' + escapeHtml(word) + '</span>');\n } else {\n out.push(escapeHtml(word));\n }\n i = j;\n continue;\n }\n // punctuation\n if (/[{}[\\](),;:]/.test(ch)) {\n out.push('<span class=\"tok-punc\">' + escapeHtml(ch) + '</span>');\n i++;\n continue;\n }\n out.push(escapeHtml(ch));\n i++;\n }\n return out.join('');\n }\n\n // =====================================================================\n // Saved queries\n // =====================================================================\n const SavedQueries = {\n async load(q) {\n if (q.kind === 'sql') {\n Router.selectTab('sql');\n $('#sqlEditor').value = q.sql || '';\n State.sql.text = q.sql || '';\n Storage.set('sqlDraft', q.sql || '');\n toast('Loaded saved SQL: ' + q.name, 'ok');\n } else if (q.kind === 'builder') {\n if (q.table) {\n const t = State.tablesByName.get(q.table);\n if (t) {\n State.currentTable = t;\n Storage.set('currentTable', t.name);\n }\n }\n Router.selectTab('builder');\n // Re-hydrating from a saved args object is tricky since our internal\n // shape differs from the flat Turbine args object. For now we store\n // the args alongside the builder table and render a hint.\n State.builder.table = q.table;\n State.builder.args = SavedQueries.hydrateBuilderArgs(q.args || {}, q.table);\n BuilderPane.render();\n toast('Loaded saved builder: ' + q.name, 'ok');\n }\n },\n\n hydrateBuilderArgs(argsObj, tableName) {\n // Best-effort inverse of buildFindManyArgs for a flat where obj\n const tableMeta = State.tablesByName.get(tableName);\n const out = emptyBuilderArgs();\n if (!tableMeta) return out;\n if (argsObj.limit) out.limit = argsObj.limit;\n if (argsObj.orderBy) {\n const k = Object.keys(argsObj.orderBy)[0];\n if (k) out.orderBy = { column: k, dir: argsObj.orderBy[k] };\n }\n if (argsObj.where) {\n const w = argsObj.where;\n const collectInto = (obj, combinator) => {\n for (const [col, val] of Object.entries(obj)) {\n if (col === 'AND' || col === 'OR' || col === 'NOT') continue;\n if (val && typeof val === 'object' && !Array.isArray(val)) {\n const op = Object.keys(val).find((k) => k !== 'mode') || 'equals';\n out.where.push({\n column: col,\n op,\n value: val[op],\n insensitive: val.mode === 'insensitive',\n });\n } else {\n out.where.push({ column: col, op: 'equals', value: val, insensitive: false });\n }\n }\n };\n if (w.OR) {\n out.combinator = 'OR';\n for (const c of w.OR) collectInto(c, 'OR');\n } else if (w.AND) {\n for (const c of w.AND) collectInto(c, 'AND');\n } else {\n collectInto(w, 'AND');\n }\n }\n if (argsObj.select) out.select = Object.keys(argsObj.select);\n if (argsObj.omit) out.omit = Object.keys(argsObj.omit);\n if (argsObj.with) {\n for (const [relName, v] of Object.entries(argsObj.with)) {\n const rel = (tableMeta.relations || []).find((r) => r.name === relName);\n if (!rel) continue;\n const childArgs = v === true ? emptyBuilderArgs() : SavedQueries.hydrateBuilderArgs(v, rel.to);\n out.with.push({ relation: relName, expanded: true, args: childArgs });\n }\n }\n return out;\n },\n\n async remove(id) {\n try {\n await Api.deleteSaved(id);\n State.savedQueries = State.savedQueries.filter((q) => q.id !== id);\n Sidebar.renderSaved();\n toast('Deleted saved query', 'ok');\n } catch (err) {\n toast('Delete failed: ' + err.message, 'err');\n }\n },\n\n async refresh() {\n try {\n const res = await Api.savedQueries();\n State.savedQueries = res.queries || [];\n Sidebar.renderSaved();\n } catch {\n // silent on refresh fail\n }\n },\n };\n\n // =====================================================================\n // Modals\n // =====================================================================\n const Modal = {\n open(id) {\n const m = document.getElementById(id);\n if (!m) return;\n m.classList.add('open');\n m.setAttribute('aria-hidden', 'false');\n setTimeout(() => {\n const inp = m.querySelector('input,textarea,select,button');\n if (inp) inp.focus();\n }, 20);\n },\n close(id) {\n const m = document.getElementById(id);\n if (!m) return;\n m.classList.remove('open');\n m.setAttribute('aria-hidden', 'true');\n },\n bind() {\n $$('[data-close-modal]').forEach((b) => {\n b.addEventListener('click', () => Modal.close(b.dataset.closeModal));\n });\n $$('.modal-backdrop').forEach((bd) => {\n bd.addEventListener('click', (e) => {\n if (e.target === bd) Modal.close(bd.id);\n });\n });\n },\n };\n\n const JsonModal = {\n open(val) {\n const host = $('#jsonModalBody');\n host.textContent = JSON.stringify(val, null, 2);\n Modal.open('jsonModalBackdrop');\n },\n };\n\n const SavePrompt = {\n fillTables(preferred) {\n const sel = $('#savePromptTable');\n sel.innerHTML = '';\n for (const t of State.tables) {\n const opt = el('option', { value: t.name, text: t.name });\n sel.appendChild(opt);\n }\n const target =\n preferred ||\n (State.currentTable && State.currentTable.name) ||\n (State.tables[0] && State.tables[0].name);\n if (target) sel.value = target;\n },\n bind() {\n $('#savePromptConfirm').addEventListener('click', async () => {\n const name = $('#savePromptName').value.trim();\n const table = $('#savePromptTable').value;\n if (!name || !table) {\n toast('Name and table required', 'err');\n return;\n }\n const payload = {\n table,\n name,\n kind: State.savePrompt.kind,\n };\n if (State.savePrompt.kind === 'sql') payload.sql = State.savePrompt.sql;\n if (State.savePrompt.kind === 'builder') payload.args = State.savePrompt.args;\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 Data tab', kbd: ['G', 'D'], run: () => Router.selectTab('data') },\n { name: 'Go to Schema tab', kbd: ['G', 'S'], run: () => Router.selectTab('schema') },\n { name: 'Go to SQL tab', kbd: ['G', 'Q'], run: () => Router.selectTab('sql') },\n { name: 'Go to Builder tab', kbd: ['G', 'B'], run: () => Router.selectTab('builder') },\n { name: 'Run SQL query', kbd: [isMac() ? '⌘' : 'Ctrl', '↵'], run: () => { Router.selectTab('sql'); setTimeout(() => SqlPane.run(), 50); } },\n { name: 'Save SQL query', kbd: [isMac() ? '⌘' : 'Ctrl', 'S'], run: () => { Router.selectTab('sql'); SqlPane.openSave(); } },\n { name: 'Refresh data', kbd: ['R'], run: () => DataPane.loadAndRender() },\n { name: 'Reload schema', kbd: ['Shift', 'R'], run: () => boot(true) },\n ];\n const filteredCmds = q\n ? cmds.filter((c) => c.name.toLowerCase().includes(q))\n : cmds;\n if (filteredCmds.length) {\n list.appendChild(el('div', { class: 'palette-section', text: 'Commands' }));\n for (const c of filteredCmds) items.push(Palette.renderItem(c.name, c.kbd, c.run));\n }\n\n // Tables\n const tables = State.tables.filter(\n (t) => !q || t.name.toLowerCase().includes(q)\n );\n if (tables.length) {\n list.appendChild(el('div', { class: 'palette-section', text: 'Tables' }));\n for (const t of tables.slice(0, 50)) {\n items.push(\n Palette.renderItem(t.name, [formatCount(t.estimatedRows)], () => {\n Router.selectTable(t.name);\n Modal.close('paletteBackdrop');\n })\n );\n }\n }\n\n if (!items.length) {\n list.appendChild(el('div', { class: 'empty', style: { padding: '24px' }, text: 'No matches.' }));\n }\n for (const it of items) list.appendChild(it);\n const first = list.querySelector('.palette-item');\n if (first) first.classList.add('selected');\n },\n\n renderItem(name, kbd, run) {\n const kbdEls = (kbd || []).map((k) => el('span', { class: 'kbd', text: k }));\n const item = el('div', { class: 'palette-item' }, [\n el('div', { class: 'pi-name' }, [\n el(\n 'svg',\n {\n class: 'pi-icon',\n viewBox: '0 0 24 24',\n fill: 'none',\n stroke: 'currentColor',\n 'stroke-width': '2',\n },\n []\n ),\n el('span', { text: name }),\n ]),\n el('div', { class: 'pi-kbd' }, kbdEls),\n ]);\n item.addEventListener('click', () => {\n run();\n Modal.close('paletteBackdrop');\n });\n return item;\n },\n };\n\n // =====================================================================\n // Keyboard shortcuts\n // =====================================================================\n function bindKeys() {\n document.addEventListener('keydown', (e) => {\n const cmd = e.metaKey || e.ctrlKey;\n // Cmd+K opens palette\n if (cmd && (e.key === 'k' || e.key === 'K')) {\n e.preventDefault();\n Palette.open();\n return;\n }\n if (e.key === 'Escape') {\n ['savePromptBackdrop', 'jsonModalBackdrop', 'paletteBackdrop'].forEach((id) =>\n Modal.close(id)\n );\n return;\n }\n if (isTypingInInput()) return;\n if (e.key === '/') {\n e.preventDefault();\n $('#sidebarSearch').focus();\n }\n if (e.key === '?') {\n Palette.open();\n }\n });\n }\n\n // =====================================================================\n // Init / boot\n // =====================================================================\n async function boot(reloadOnly) {\n try {\n const schema = await Api.schema();\n State.schema = schema;\n State.tables = schema.tables || [];\n State.tablesByName = new Map(State.tables.map((t) => [t.name, t]));\n State.enums = schema.enums || {};\n State.connOk = true;\n $('#connDot').classList.add('ok');\n $('#connLabel').textContent = 'connected';\n const meta =\n (schema.schema || 'public') + ' · ' + State.tables.length + ' tables';\n $('#headerMeta').textContent = meta;\n $('#sidebarMeta').textContent = meta;\n\n // Restore\n const savedW = Storage.get('sidebarW');\n if (savedW) document.documentElement.style.setProperty('--sidebar-w', savedW);\n const savedTab = Storage.get('tab', 'data');\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 || 'data');\n if (State.currentTable) {\n if (savedTab === 'data' || !savedTab) DataPane.loadAndRender();\n if (savedTab === 'schema') SchemaPane.render();\n if (savedTab === 'builder') BuilderPane.resetForTable(State.currentTable.name);\n }\n\n // SQL draft\n const sqlDraft = Storage.get('sqlDraft', '');\n if (sqlDraft) $('#sqlEditor').value = sqlDraft;\n\n SavedQueries.refresh();\n } catch (err) {\n State.connOk = false;\n $('#connDot').classList.remove('ok');\n $('#connDot').classList.add('err');\n $('#connLabel').textContent = 'offline';\n $('#headerMeta').textContent = 'connection failed';\n $('#sidebarMeta').textContent = '—';\n const mainErr = el('div', { class: 'error-box' }, [\n el('div', { class: 'error-box-title', text: 'Cannot reach /api/schema' }),\n document.createTextNode(err.message),\n ]);\n $('#dataScroll').innerHTML = '';\n $('#dataScroll').appendChild(mainErr);\n }\n }\n\n function init() {\n Sidebar.bind();\n Router.bind();\n DataPane.bind();\n SqlPane.bind();\n BuilderPane.bind();\n Modal.bind();\n SavePrompt.bind();\n Palette.bind();\n bindKeys();\n boot();\n }\n\n document.addEventListener('DOMContentLoaded', init);\n </script>\n</body>\n</html>\n";
|
|
3
|
+
export const 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 Reset + base\n ===================================================================== */\n *,\n *::before,\n *::after {\n box-sizing: border-box;\n }\n html,\n body {\n margin: 0;\n padding: 0;\n height: 100%;\n background: var(--bg);\n color: var(--text);\n font-family: var(--sans);\n font-size: 13px;\n line-height: 1.5;\n -webkit-font-smoothing: antialiased;\n text-rendering: optimizeLegibility;\n }\n body {\n overflow: hidden;\n }\n button {\n font-family: inherit;\n font-size: inherit;\n color: inherit;\n background: none;\n border: none;\n cursor: pointer;\n padding: 0;\n }\n input,\n textarea,\n select {\n font-family: inherit;\n font-size: inherit;\n color: inherit;\n }\n ::selection {\n background: var(--accent-dim);\n color: var(--text);\n }\n ::-webkit-scrollbar {\n width: 10px;\n height: 10px;\n }\n ::-webkit-scrollbar-track {\n background: transparent;\n }\n ::-webkit-scrollbar-thumb {\n background: #2a2a30;\n border-radius: 10px;\n border: 2px solid var(--bg);\n }\n ::-webkit-scrollbar-thumb:hover {\n background: #3a3a42;\n }\n :focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n border-radius: 3px;\n }\n a {\n color: var(--accent);\n text-decoration: none;\n }\n a:hover {\n color: var(--accent-hover);\n }\n\n /* =====================================================================\n App shell\n ===================================================================== */\n .app {\n display: grid;\n grid-template-rows: var(--header-h) 1fr;\n height: 100vh;\n width: 100vw;\n }\n\n /* Header */\n .header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 0 16px;\n border-bottom: 1px solid var(--border);\n background: var(--bg-elev);\n height: var(--header-h);\n user-select: none;\n }\n .brand {\n display: flex;\n align-items: center;\n gap: 10px;\n font-family: var(--mono);\n font-size: 13px;\n font-weight: 600;\n letter-spacing: -0.01em;\n }\n .brand-mark {\n width: 18px;\n height: 18px;\n border-radius: 4px;\n background: linear-gradient(135deg, var(--accent) 0%, var(--purple) 100%);\n position: relative;\n }\n .brand-mark::after {\n content: \"\";\n position: absolute;\n inset: 4px;\n border-radius: 2px;\n background: var(--bg-elev);\n }\n .brand-dot {\n color: var(--text-muted);\n }\n .brand-sub {\n color: var(--text-dim);\n font-weight: 400;\n }\n .header-right {\n display: flex;\n align-items: center;\n gap: 14px;\n font-size: 12px;\n color: var(--text-dim);\n }\n .header-meta {\n font-family: var(--mono);\n font-size: 11.5px;\n }\n .header-meta .sep {\n color: var(--text-muted);\n margin: 0 6px;\n }\n .conn {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n color: var(--text-dim);\n }\n .conn-dot {\n width: 7px;\n height: 7px;\n border-radius: 50%;\n background: var(--text-muted);\n transition: background var(--t), box-shadow var(--t);\n }\n .conn-dot.ok {\n background: var(--green);\n box-shadow: 0 0 0 3px rgba(74, 222, 128, 0.15);\n }\n .conn-dot.err {\n background: var(--red);\n box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.15);\n }\n .kbd {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n min-width: 18px;\n height: 18px;\n padding: 0 5px;\n border: 1px solid var(--border-strong);\n border-bottom-width: 2px;\n border-radius: 4px;\n background: var(--bg-elev);\n font-family: var(--mono);\n font-size: 10.5px;\n color: var(--text-dim);\n }\n .header-cmd {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 4px 8px 4px 10px;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: var(--bg);\n color: var(--text-dim);\n font-size: 12px;\n transition: border-color var(--t), color var(--t);\n }\n .header-cmd:hover {\n border-color: var(--border-strong);\n color: var(--text);\n }\n .icon-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n border-radius: var(--radius);\n color: var(--text-dim);\n transition: background var(--t), color var(--t);\n }\n .icon-btn:hover {\n background: var(--bg-hover);\n color: var(--text);\n }\n .hamburger {\n display: none;\n }\n\n /* Body layout */\n .body {\n display: grid;\n grid-template-columns: var(--sidebar-w) 1fr;\n min-height: 0;\n overflow: hidden;\n position: relative;\n }\n\n /* =====================================================================\n Sidebar\n ===================================================================== */\n .sidebar {\n display: flex;\n flex-direction: column;\n border-right: 1px solid var(--border);\n background: var(--bg-elev);\n min-width: 0;\n position: relative;\n }\n .sidebar-header {\n padding: 14px 16px 10px;\n border-bottom: 1px solid var(--border);\n }\n .sidebar-title {\n font-family: var(--mono);\n font-size: 11px;\n font-weight: 600;\n color: var(--text);\n letter-spacing: 0.02em;\n text-transform: lowercase;\n display: flex;\n align-items: center;\n gap: 6px;\n }\n .sidebar-meta {\n font-family: var(--mono);\n font-size: 10.5px;\n color: var(--text-muted);\n margin-top: 4px;\n letter-spacing: 0.02em;\n }\n .sidebar-search {\n margin: 10px 12px 4px;\n position: relative;\n }\n .sidebar-search input {\n width: 100%;\n padding: 7px 10px 7px 28px;\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n color: var(--text);\n font-size: 12px;\n transition: border-color var(--t), background var(--t);\n }\n .sidebar-search input:focus {\n outline: none;\n border-color: var(--border-strong);\n }\n .sidebar-search input::placeholder {\n color: var(--text-muted);\n }\n .sidebar-search-icon {\n position: absolute;\n left: 9px;\n top: 50%;\n transform: translateY(-50%);\n color: var(--text-muted);\n width: 13px;\n height: 13px;\n pointer-events: none;\n }\n .sidebar-scroll {\n flex: 1;\n overflow-y: auto;\n padding: 8px 0 16px;\n }\n .sidebar-group {\n padding: 4px 0;\n }\n .sidebar-group-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 16px 4px;\n font-family: var(--mono);\n font-size: 10px;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n cursor: pointer;\n user-select: none;\n transition: color var(--t);\n }\n .sidebar-group-header:hover {\n color: var(--text-dim);\n }\n .sidebar-group-header .chev {\n transition: transform var(--t);\n }\n .sidebar-group.collapsed .chev {\n transform: rotate(-90deg);\n }\n .sidebar-group.collapsed .sidebar-group-body {\n display: none;\n }\n .sidebar-group-body {\n display: flex;\n flex-direction: column;\n }\n .sidebar-item {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 5px 16px 5px 22px;\n font-size: 12.5px;\n font-family: var(--mono);\n color: var(--text-dim);\n cursor: pointer;\n transition: background var(--t), color var(--t);\n user-select: none;\n min-width: 0;\n }\n .sidebar-item-name {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n min-width: 0;\n }\n .sidebar-item-count {\n margin-left: 8px;\n font-size: 10.5px;\n color: var(--text-muted);\n flex-shrink: 0;\n }\n .sidebar-item:hover {\n background: var(--bg-hover);\n color: var(--text);\n }\n .sidebar-item.active {\n background: var(--bg-active);\n color: var(--text);\n border-left: 2px solid var(--accent);\n padding-left: 20px;\n }\n .sidebar-item.active .sidebar-item-count {\n color: var(--text-dim);\n }\n .sidebar-empty {\n padding: 6px 22px;\n font-size: 11.5px;\n color: var(--text-muted);\n font-style: italic;\n }\n\n .saved-group {\n margin-top: 6px;\n }\n .saved-table-header {\n padding: 6px 16px 3px 16px;\n font-family: var(--mono);\n font-size: 10px;\n color: var(--text-muted);\n text-transform: lowercase;\n letter-spacing: 0.02em;\n }\n .saved-query {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 4px 16px 4px 22px;\n font-size: 12px;\n color: var(--text-dim);\n cursor: pointer;\n transition: background var(--t), color var(--t);\n }\n .saved-query:hover {\n background: var(--bg-hover);\n color: var(--text);\n }\n .saved-query .saved-kind {\n font-family: var(--mono);\n font-size: 9px;\n padding: 1px 5px;\n border-radius: 3px;\n border: 1px solid var(--border);\n color: var(--text-muted);\n text-transform: uppercase;\n }\n .saved-query-name {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n flex: 1;\n }\n .saved-query-del {\n opacity: 0;\n transition: opacity var(--t);\n color: var(--text-muted);\n padding: 0 2px;\n }\n .saved-query:hover .saved-query-del {\n opacity: 1;\n }\n .saved-query-del:hover {\n color: var(--red);\n }\n\n /* Sidebar resizer */\n .sidebar-resizer {\n position: absolute;\n top: 0;\n right: -3px;\n width: 6px;\n height: 100%;\n cursor: col-resize;\n z-index: 10;\n user-select: none;\n }\n .sidebar-resizer:hover::after,\n .sidebar-resizer.dragging::after {\n content: \"\";\n position: absolute;\n right: 2px;\n top: 0;\n width: 2px;\n height: 100%;\n background: var(--accent);\n }\n\n /* =====================================================================\n Main area\n ===================================================================== */\n .main {\n display: flex;\n flex-direction: column;\n min-width: 0;\n min-height: 0;\n overflow: hidden;\n }\n .tabs {\n display: flex;\n align-items: center;\n gap: 2px;\n padding: 0 16px;\n border-bottom: 1px solid var(--border);\n background: var(--bg-elev);\n height: 38px;\n flex-shrink: 0;\n }\n .tab {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n padding: 0 12px;\n height: 38px;\n font-size: 12.5px;\n color: var(--text-dim);\n border-bottom: 2px solid transparent;\n margin-bottom: -1px;\n transition: color var(--t), border-color var(--t);\n user-select: none;\n }\n .tab:hover {\n color: var(--text);\n }\n .tab.active {\n color: var(--text);\n border-bottom-color: var(--accent);\n }\n .tab-icon {\n width: 13px;\n height: 13px;\n opacity: 0.7;\n }\n .tab.active .tab-icon {\n opacity: 1;\n }\n\n .pane {\n flex: 1;\n min-height: 0;\n display: none;\n flex-direction: column;\n overflow: hidden;\n }\n .pane.active {\n display: flex;\n }\n .pane-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 16px;\n padding: 14px 20px;\n border-bottom: 1px solid var(--border);\n flex-shrink: 0;\n }\n .pane-title {\n display: flex;\n align-items: baseline;\n gap: 10px;\n min-width: 0;\n }\n .pane-title h1 {\n margin: 0;\n font-size: 18px;\n font-weight: 600;\n font-family: var(--mono);\n letter-spacing: -0.01em;\n color: var(--text);\n }\n .pane-title .muted {\n font-size: 12px;\n color: var(--text-muted);\n font-family: var(--mono);\n }\n .toolbar {\n display: flex;\n align-items: center;\n gap: 8px;\n }\n .search-input {\n position: relative;\n }\n .search-input input {\n padding: 6px 10px 6px 28px;\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n color: var(--text);\n font-size: 12px;\n width: 220px;\n transition: border-color var(--t), width var(--t);\n font-family: var(--mono);\n }\n .search-input input:focus {\n outline: none;\n border-color: var(--border-strong);\n width: 280px;\n }\n .search-input-icon {\n position: absolute;\n left: 9px;\n top: 50%;\n transform: translateY(-50%);\n color: var(--text-muted);\n width: 13px;\n height: 13px;\n pointer-events: none;\n }\n\n /* Buttons */\n .btn {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 6px 12px;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: var(--bg);\n color: var(--text);\n font-size: 12px;\n transition: all var(--t);\n white-space: nowrap;\n }\n .btn:hover {\n background: var(--bg-hover);\n border-color: var(--border-strong);\n }\n .btn:disabled {\n opacity: 0.5;\n cursor: not-allowed;\n }\n .btn-primary {\n background: var(--accent);\n border-color: var(--accent);\n color: #0a0a0b;\n font-weight: 500;\n }\n .btn-primary:hover:not(:disabled) {\n background: var(--accent-hover);\n border-color: var(--accent-hover);\n }\n .btn-ghost {\n border-color: transparent;\n background: transparent;\n color: var(--text-dim);\n }\n .btn-ghost:hover {\n background: var(--bg-hover);\n color: var(--text);\n }\n .btn-sm {\n padding: 4px 8px;\n font-size: 11px;\n }\n\n /* =====================================================================\n Data table\n ===================================================================== */\n .data-wrap {\n flex: 1;\n min-height: 0;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n .data-scroll {\n flex: 1;\n overflow: auto;\n min-height: 0;\n position: relative;\n }\n table.data {\n border-collapse: separate;\n border-spacing: 0;\n width: 100%;\n font-family: var(--mono);\n font-size: 12px;\n }\n table.data thead th {\n position: sticky;\n top: 0;\n background: var(--bg-elev);\n border-bottom: 1px solid var(--border);\n padding: 8px 14px;\n text-align: left;\n font-weight: 500;\n color: var(--text-dim);\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n white-space: nowrap;\n z-index: 2;\n user-select: none;\n }\n table.data thead th.sortable {\n cursor: pointer;\n transition: color var(--t), background var(--t);\n }\n table.data thead th.sortable:hover {\n color: var(--text);\n background: var(--bg-hover);\n }\n table.data thead th .sort-ind {\n color: var(--accent);\n margin-left: 4px;\n font-size: 10px;\n }\n table.data tbody td {\n padding: 7px 14px;\n border-bottom: 1px solid var(--border);\n color: var(--text);\n max-width: 360px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n vertical-align: middle;\n }\n table.data tbody tr {\n transition: background var(--t);\n }\n table.data tbody tr:hover {\n background: var(--bg-hover);\n }\n .cell-null {\n color: var(--text-muted);\n font-style: italic;\n }\n .cell-json {\n color: var(--purple);\n }\n .cell-json .expand-btn {\n margin-left: 6px;\n font-size: 10px;\n color: var(--text-muted);\n padding: 1px 4px;\n border: 1px solid var(--border);\n border-radius: 3px;\n transition: all var(--t);\n }\n .cell-json .expand-btn:hover {\n color: var(--accent);\n border-color: var(--accent);\n }\n .cell-number {\n color: var(--green);\n }\n .cell-bool {\n color: var(--orange);\n }\n .data-footer {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 10px 20px;\n border-top: 1px solid var(--border);\n background: var(--bg-elev);\n font-size: 11.5px;\n color: var(--text-dim);\n flex-shrink: 0;\n font-family: var(--mono);\n }\n .pagination {\n display: flex;\n align-items: center;\n gap: 8px;\n }\n\n /* States */\n .empty,\n .error-box,\n .loading {\n padding: 40px 24px;\n text-align: center;\n color: var(--text-dim);\n font-size: 13px;\n }\n .empty-icon {\n margin: 0 auto 10px;\n width: 28px;\n height: 28px;\n color: var(--text-muted);\n }\n .error-box {\n margin: 16px;\n padding: 14px 18px;\n border: 1px solid rgba(248, 113, 113, 0.35);\n background: rgba(248, 113, 113, 0.06);\n border-radius: var(--radius);\n color: var(--red);\n text-align: left;\n font-family: var(--mono);\n font-size: 12px;\n white-space: pre-wrap;\n }\n .error-box-title {\n font-weight: 600;\n margin-bottom: 4px;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n font-size: 10.5px;\n }\n\n /* Skeleton */\n @keyframes shimmer {\n 0% {\n background-position: -200% 0;\n }\n 100% {\n background-position: 200% 0;\n }\n }\n .skeleton-row {\n display: flex;\n gap: 16px;\n padding: 10px 14px;\n border-bottom: 1px solid var(--border);\n }\n .skeleton-cell {\n height: 10px;\n border-radius: 3px;\n background: linear-gradient(\n 90deg,\n #1a1a1d 0%,\n #22222a 50%,\n #1a1a1d 100%\n );\n background-size: 200% 100%;\n animation: shimmer 1.4s linear infinite;\n flex: 1;\n }\n\n /* =====================================================================\n Schema pane\n ===================================================================== */\n .schema-scroll {\n flex: 1;\n overflow: auto;\n padding: 24px 32px 40px;\n }\n .schema-section {\n margin-bottom: 32px;\n }\n .schema-section h2 {\n font-size: 11px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-dim);\n margin: 0 0 12px;\n font-family: var(--mono);\n }\n .col-table {\n width: 100%;\n border-collapse: separate;\n border-spacing: 0;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n overflow: hidden;\n background: var(--bg-elev);\n }\n .col-table th,\n .col-table td {\n padding: 9px 14px;\n text-align: left;\n font-family: var(--mono);\n font-size: 12px;\n border-bottom: 1px solid var(--border);\n }\n .col-table th {\n font-size: 10.5px;\n font-weight: 500;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n color: var(--text-muted);\n background: var(--bg);\n }\n .col-table tr:last-child td {\n border-bottom: none;\n }\n .col-name {\n color: var(--text);\n font-weight: 500;\n }\n .col-name.pk {\n color: var(--orange);\n }\n .col-ts {\n color: var(--green);\n }\n .col-pg {\n color: var(--text-dim);\n }\n .col-rel-name {\n color: var(--accent);\n }\n .badge {\n display: inline-block;\n padding: 1px 6px;\n border-radius: 3px;\n font-size: 9.5px;\n font-weight: 500;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n margin-right: 4px;\n font-family: var(--mono);\n border: 1px solid transparent;\n }\n .badge.pk {\n background: rgba(251, 146, 60, 0.12);\n color: var(--orange);\n border-color: rgba(251, 146, 60, 0.25);\n }\n .badge.nn {\n background: rgba(248, 113, 113, 0.1);\n color: var(--red);\n border-color: rgba(248, 113, 113, 0.2);\n }\n .badge.def {\n background: rgba(167, 139, 250, 0.1);\n color: var(--purple);\n border-color: rgba(167, 139, 250, 0.2);\n }\n .badge.nullable {\n background: var(--bg);\n color: var(--text-muted);\n border-color: var(--border);\n }\n .badge.rel-type {\n background: var(--accent-dim);\n color: var(--accent);\n border-color: rgba(96, 165, 250, 0.25);\n }\n .schema-details {\n margin-top: 18px;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: var(--bg-elev);\n overflow: hidden;\n }\n .schema-details summary {\n padding: 10px 14px;\n font-family: var(--mono);\n font-size: 11px;\n color: var(--text-dim);\n cursor: pointer;\n user-select: none;\n list-style: none;\n display: flex;\n align-items: center;\n gap: 8px;\n }\n .schema-details summary::-webkit-details-marker {\n display: none;\n }\n .schema-details summary::before {\n content: \"›\";\n color: var(--text-muted);\n transition: transform var(--t);\n display: inline-block;\n }\n .schema-details[open] summary::before {\n transform: rotate(90deg);\n }\n .schema-details pre {\n margin: 0;\n padding: 14px 16px;\n font-family: var(--mono);\n font-size: 11.5px;\n line-height: 1.6;\n color: var(--text-dim);\n overflow-x: auto;\n border-top: 1px solid var(--border);\n background: var(--bg);\n }\n .sql-kw {\n color: var(--accent);\n font-weight: 500;\n }\n .sql-type {\n color: var(--green);\n }\n .sql-ident {\n color: var(--text);\n }\n .sql-str {\n color: var(--yellow);\n }\n\n /* =====================================================================\n SQL pane\n ===================================================================== */\n .sql-pane {\n display: flex;\n flex-direction: column;\n min-height: 0;\n }\n .sql-editor-wrap {\n display: flex;\n flex-direction: column;\n border-bottom: 1px solid var(--border);\n padding: 14px 20px;\n gap: 10px;\n }\n .sql-toolbar {\n display: flex;\n justify-content: space-between;\n align-items: center;\n }\n .sql-toolbar-left {\n display: flex;\n gap: 8px;\n align-items: center;\n }\n .sql-editor {\n width: 100%;\n min-height: 140px;\n max-height: 320px;\n background: var(--bg-elev);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 12px 14px;\n font-family: var(--mono);\n font-size: 12.5px;\n color: var(--text);\n line-height: 1.55;\n resize: vertical;\n transition: border-color var(--t);\n tab-size: 2;\n }\n .sql-editor:focus {\n outline: none;\n border-color: var(--border-strong);\n }\n .sql-editor::placeholder {\n color: var(--text-muted);\n }\n .sql-meta {\n padding: 10px 20px;\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 display: flex;\n gap: 14px;\n flex-shrink: 0;\n }\n .sql-meta .sep {\n color: var(--text-muted);\n }\n .sql-meta .ok {\n color: var(--green);\n }\n\n /* =====================================================================\n Builder pane\n ===================================================================== */\n .builder-wrap {\n flex: 1;\n min-height: 0;\n display: grid;\n grid-template-columns: 1fr 1fr;\n min-width: 0;\n }\n .builder-left {\n border-right: 1px solid var(--border);\n overflow-y: auto;\n padding: 20px 24px;\n }\n .builder-right {\n display: flex;\n flex-direction: column;\n min-height: 0;\n background: var(--bg);\n min-width: 0;\n }\n .builder-preview {\n padding: 18px 22px;\n font-family: var(--mono);\n font-size: 12.5px;\n line-height: 1.65;\n color: var(--text);\n white-space: pre;\n overflow: auto;\n flex: 1;\n min-height: 0;\n border-bottom: 1px solid var(--border);\n }\n .tok-kw {\n color: var(--accent);\n }\n .tok-fn {\n color: var(--yellow);\n }\n .tok-str {\n color: var(--green);\n }\n .tok-num {\n color: var(--orange);\n }\n .tok-key {\n color: var(--purple);\n }\n .tok-punc {\n color: var(--text-dim);\n }\n .tok-comment {\n color: var(--text-muted);\n font-style: italic;\n }\n .builder-results {\n flex: 1;\n min-height: 200px;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n .builder-results-header {\n padding: 10px 22px;\n font-family: var(--mono);\n font-size: 11px;\n color: var(--text-dim);\n border-bottom: 1px solid var(--border);\n background: var(--bg-elev);\n text-transform: uppercase;\n letter-spacing: 0.04em;\n flex-shrink: 0;\n }\n\n .builder-section {\n margin-bottom: 18px;\n }\n .builder-section-title {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding-bottom: 8px;\n margin-bottom: 10px;\n border-bottom: 1px dashed var(--border);\n }\n .builder-section-title h3 {\n margin: 0;\n font-size: 11px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.06em;\n color: var(--text-dim);\n font-family: var(--mono);\n }\n .builder-field {\n margin-bottom: 10px;\n }\n .builder-field label {\n display: block;\n font-size: 11px;\n color: var(--text-muted);\n margin-bottom: 4px;\n font-family: var(--mono);\n text-transform: uppercase;\n letter-spacing: 0.04em;\n }\n .builder-input,\n .builder-select {\n width: 100%;\n padding: 6px 10px;\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n color: var(--text);\n font-size: 12px;\n font-family: var(--mono);\n transition: border-color var(--t);\n }\n .builder-input:focus,\n .builder-select:focus {\n outline: none;\n border-color: var(--border-strong);\n }\n .builder-input.invalid,\n .builder-select.invalid {\n border-color: rgba(248, 113, 113, 0.5);\n }\n .field-hint {\n font-size: 10.5px;\n color: var(--red);\n margin-top: 3px;\n font-family: var(--mono);\n }\n .where-clauses {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n .where-clause {\n display: grid;\n grid-template-columns: 1fr 1fr 1.2fr auto;\n gap: 6px;\n align-items: center;\n }\n .where-clause .btn-ghost {\n padding: 4px 6px;\n }\n .clause-row {\n display: flex;\n gap: 6px;\n align-items: center;\n }\n .combinator-pick {\n display: flex;\n gap: 0;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n overflow: hidden;\n background: var(--bg);\n }\n .combinator-pick button {\n padding: 4px 10px;\n font-family: var(--mono);\n font-size: 10.5px;\n color: var(--text-dim);\n border-right: 1px solid var(--border);\n transition: background var(--t), color var(--t);\n }\n .combinator-pick button:last-child {\n border-right: none;\n }\n .combinator-pick button.active {\n background: var(--accent-dim);\n color: var(--accent);\n }\n .combinator-pick button:hover:not(.active) {\n background: var(--bg-hover);\n }\n .with-rels {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n .with-rel {\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: var(--bg);\n }\n .with-rel-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 7px 10px;\n cursor: pointer;\n user-select: none;\n gap: 8px;\n }\n .with-rel-header:hover {\n background: var(--bg-hover);\n }\n .with-rel-name {\n font-family: var(--mono);\n font-size: 11.5px;\n color: var(--accent);\n }\n .with-rel-meta {\n font-family: var(--mono);\n font-size: 10px;\n color: var(--text-muted);\n }\n .with-rel-body {\n padding: 10px;\n border-top: 1px solid var(--border);\n display: none;\n }\n .with-rel.expanded .with-rel-body {\n display: block;\n }\n .with-rel .chev {\n transition: transform var(--t);\n color: var(--text-muted);\n }\n .with-rel.expanded .chev {\n transform: rotate(90deg);\n }\n .chip-row {\n display: flex;\n gap: 6px;\n flex-wrap: wrap;\n }\n .chip {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n padding: 3px 8px;\n border: 1px solid var(--border);\n border-radius: 20px;\n background: var(--bg);\n font-family: var(--mono);\n font-size: 10.5px;\n color: var(--text-dim);\n cursor: pointer;\n transition: all var(--t);\n }\n .chip:hover {\n border-color: var(--border-strong);\n color: var(--text);\n }\n .chip.active {\n background: var(--accent-dim);\n border-color: rgba(96, 165, 250, 0.35);\n color: var(--accent);\n }\n\n /* =====================================================================\n Modal / dialog\n ===================================================================== */\n .modal-backdrop {\n position: fixed;\n inset: 0;\n background: rgba(0, 0, 0, 0.65);\n display: none;\n align-items: center;\n justify-content: center;\n z-index: 100;\n backdrop-filter: blur(4px);\n }\n .modal-backdrop.open {\n display: flex;\n animation: fade-in 150ms var(--ease);\n }\n @keyframes fade-in {\n from {\n opacity: 0;\n }\n to {\n opacity: 1;\n }\n }\n @keyframes slide-up {\n from {\n opacity: 0;\n transform: translateY(8px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n }\n .modal {\n background: var(--bg-elev);\n border: 1px solid var(--border);\n border-radius: var(--radius-lg);\n box-shadow: var(--shadow-lg);\n min-width: 400px;\n max-width: 90vw;\n max-height: 85vh;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n animation: slide-up 180ms var(--ease);\n }\n .modal-header {\n padding: 14px 18px;\n border-bottom: 1px solid var(--border);\n display: flex;\n align-items: center;\n justify-content: space-between;\n }\n .modal-title {\n font-size: 13px;\n font-weight: 600;\n color: var(--text);\n font-family: var(--mono);\n }\n .modal-body {\n padding: 16px 18px;\n overflow-y: auto;\n flex: 1;\n min-height: 0;\n }\n .modal-footer {\n padding: 12px 18px;\n border-top: 1px solid var(--border);\n display: flex;\n justify-content: flex-end;\n gap: 8px;\n }\n .modal-field {\n margin-bottom: 12px;\n }\n .modal-field label {\n display: block;\n font-size: 11px;\n color: var(--text-dim);\n margin-bottom: 5px;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n font-family: var(--mono);\n }\n .modal-field input,\n .modal-field select {\n width: 100%;\n padding: 7px 10px;\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n color: var(--text);\n font-family: var(--mono);\n font-size: 12px;\n }\n .modal-field input:focus,\n .modal-field select:focus {\n outline: none;\n border-color: var(--border-strong);\n }\n .json-view {\n font-family: var(--mono);\n font-size: 12px;\n color: var(--text);\n white-space: pre-wrap;\n word-break: break-word;\n }\n\n /* =====================================================================\n Command palette\n ===================================================================== */\n .palette {\n width: 560px;\n max-width: 92vw;\n }\n .palette-input {\n width: 100%;\n padding: 14px 18px;\n background: transparent;\n border: none;\n border-bottom: 1px solid var(--border);\n color: var(--text);\n font-size: 14px;\n font-family: var(--sans);\n }\n .palette-input:focus {\n outline: none;\n }\n .palette-input::placeholder {\n color: var(--text-muted);\n }\n .palette-list {\n max-height: 50vh;\n overflow-y: auto;\n padding: 6px 0;\n }\n .palette-section {\n padding: 8px 16px 4px;\n font-size: 10px;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n font-family: var(--mono);\n }\n .palette-item {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 16px;\n cursor: pointer;\n transition: background var(--t);\n font-size: 13px;\n }\n .palette-item:hover,\n .palette-item.selected {\n background: var(--bg-hover);\n }\n .palette-item .pi-name {\n display: flex;\n align-items: center;\n gap: 10px;\n }\n .palette-item .pi-icon {\n color: var(--text-dim);\n width: 14px;\n height: 14px;\n }\n .palette-item .pi-kbd {\n display: flex;\n gap: 3px;\n }\n\n /* =====================================================================\n Responsive\n ===================================================================== */\n @media (max-width: 1024px) {\n .body {\n grid-template-columns: 1fr;\n }\n .sidebar {\n position: fixed;\n top: var(--header-h);\n left: 0;\n bottom: 0;\n width: 280px;\n transform: translateX(-100%);\n transition: transform 200ms var(--ease);\n z-index: 50;\n }\n .sidebar.open {\n transform: translateX(0);\n }\n .hamburger {\n display: inline-flex;\n }\n .sidebar-resizer {\n display: none;\n }\n .search-input input {\n width: 160px;\n }\n .search-input input:focus {\n width: 200px;\n }\n }\n\n .toast-wrap {\n position: fixed;\n bottom: 24px;\n right: 24px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n z-index: 200;\n }\n .toast {\n padding: 10px 14px;\n background: var(--bg-elev);\n border: 1px solid var(--border-strong);\n border-radius: var(--radius);\n font-size: 12px;\n color: var(--text);\n box-shadow: var(--shadow-lg);\n animation: slide-up 200ms var(--ease);\n max-width: 320px;\n }\n .toast.ok {\n border-left: 3px solid var(--green);\n }\n .toast.err {\n border-left: 3px solid var(--red);\n }\n </style>\n</head>\n<body>\n <div class=\"app\" id=\"app\">\n <!-- Header -->\n <header class=\"header\">\n <div class=\"brand\">\n <button class=\"icon-btn hamburger\" id=\"hamburger\" aria-label=\"Toggle sidebar\">\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <path d=\"M3 6h18M3 12h18M3 18h18\" />\n </svg>\n </button>\n <span class=\"brand-mark\" aria-hidden=\"true\"></span>\n <span>turbine</span>\n <span class=\"brand-dot\">·</span>\n <span class=\"brand-sub\">studio</span>\n </div>\n <div class=\"header-right\">\n <button class=\"header-cmd\" id=\"openPalette\" aria-label=\"Open command palette\">\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <circle cx=\"11\" cy=\"11\" r=\"7\" />\n <path d=\"m21 21-4.3-4.3\" />\n </svg>\n <span>Jump to…</span>\n <span class=\"kbd\">⌘K</span>\n </button>\n <span class=\"header-meta\" id=\"headerMeta\">—</span>\n <div class=\"conn\" id=\"conn\">\n <span class=\"conn-dot\" id=\"connDot\"></span>\n <span id=\"connLabel\">connecting</span>\n </div>\n </div>\n </header>\n\n <div class=\"body\">\n <!-- Sidebar -->\n <aside class=\"sidebar\" id=\"sidebar\" aria-label=\"Schema sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"sidebar-title\">\n <span>schema</span>\n </div>\n <div class=\"sidebar-meta\" id=\"sidebarMeta\">—</div>\n </div>\n <div class=\"sidebar-search\">\n <svg class=\"sidebar-search-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <circle cx=\"11\" cy=\"11\" r=\"7\" />\n <path d=\"m21 21-4.3-4.3\" />\n </svg>\n <input id=\"sidebarSearch\" type=\"text\" placeholder=\"Filter tables…\" aria-label=\"Filter tables\" />\n </div>\n <div class=\"sidebar-scroll\">\n <div class=\"sidebar-group\" id=\"groupTables\">\n <div class=\"sidebar-group-header\" data-group=\"groupTables\">\n <span>Tables</span>\n <span class=\"chev\">▾</span>\n </div>\n <div class=\"sidebar-group-body\" id=\"tablesList\"></div>\n </div>\n <div class=\"sidebar-group collapsed\" id=\"groupEnums\">\n <div class=\"sidebar-group-header\" data-group=\"groupEnums\">\n <span>Enums</span>\n <span class=\"chev\">▾</span>\n </div>\n <div class=\"sidebar-group-body\" id=\"enumsList\"></div>\n </div>\n <div class=\"sidebar-group\" id=\"groupSaved\">\n <div class=\"sidebar-group-header\" data-group=\"groupSaved\">\n <span>Saved Queries</span>\n <span class=\"chev\">▾</span>\n </div>\n <div class=\"sidebar-group-body\" id=\"savedList\"></div>\n </div>\n </div>\n <div class=\"sidebar-resizer\" id=\"sidebarResizer\"></div>\n </aside>\n\n <!-- Main -->\n <main class=\"main\">\n <nav class=\"tabs\" role=\"tablist\">\n <button class=\"tab active\" data-tab=\"builder\" role=\"tab\" aria-selected=\"true\">\n <svg class=\"tab-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <path d=\"M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z\" />\n </svg>\n Query\n </button>\n <button class=\"tab\" data-tab=\"data\" role=\"tab\">\n <svg class=\"tab-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" />\n <path d=\"M3 9h18M3 15h18M9 3v18M15 3v18\" />\n </svg>\n Data\n </button>\n <button class=\"tab\" data-tab=\"schema\" role=\"tab\">\n <svg class=\"tab-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <ellipse cx=\"12\" cy=\"5\" rx=\"9\" ry=\"3\" />\n <path d=\"M3 5v14a9 3 0 0 0 18 0V5\" />\n <path d=\"M3 12a9 3 0 0 0 18 0\" />\n </svg>\n Schema\n </button>\n </nav>\n\n <!-- Data pane -->\n <section class=\"pane\" id=\"pane-data\" role=\"tabpanel\">\n <div class=\"pane-header\">\n <div class=\"pane-title\">\n <h1 id=\"dataTitle\">—</h1>\n <span class=\"muted\" id=\"dataTotal\"></span>\n </div>\n <div class=\"toolbar\">\n <div class=\"search-input\">\n <svg class=\"search-input-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <circle cx=\"11\" cy=\"11\" r=\"7\" />\n <path d=\"m21 21-4.3-4.3\" />\n </svg>\n <input id=\"dataSearch\" type=\"text\" placeholder=\"Search all text columns…\" aria-label=\"Full text search\" />\n </div>\n <button class=\"btn btn-ghost btn-sm\" id=\"dataRefresh\" aria-label=\"Refresh\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <path d=\"M21 12a9 9 0 1 1-3-6.7L21 8\" />\n <path d=\"M21 3v5h-5\" />\n </svg>\n Refresh\n </button>\n </div>\n </div>\n <div class=\"data-wrap\">\n <div class=\"data-scroll\" id=\"dataScroll\"></div>\n <div class=\"data-footer\">\n <span id=\"dataRange\">—</span>\n <div class=\"pagination\">\n <button class=\"btn btn-sm\" id=\"pagePrev\">← Prev</button>\n <span id=\"pageIndicator\">—</span>\n <button class=\"btn btn-sm\" id=\"pageNext\">Next →</button>\n </div>\n </div>\n </div>\n </section>\n\n <!-- Schema pane -->\n <section class=\"pane\" id=\"pane-schema\" role=\"tabpanel\">\n <div class=\"pane-header\">\n <div class=\"pane-title\">\n <h1 id=\"schemaTitle\">—</h1>\n <span class=\"muted\" id=\"schemaSub\"></span>\n </div>\n </div>\n <div class=\"schema-scroll\" id=\"schemaScroll\"></div>\n </section>\n\n <!-- SQL pane -->\n <!-- Query (visual findMany builder) pane -->\n <section class=\"pane active\" id=\"pane-builder\" role=\"tabpanel\">\n <div class=\"pane-header\">\n <div class=\"pane-title\">\n <h1>Query</h1>\n <span class=\"muted\" id=\"builderSub\">Visual findMany composer — no SQL required</span>\n </div>\n <div class=\"toolbar\">\n <button class=\"btn btn-primary\" id=\"builderRun\">\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"currentColor\">\n <path d=\"M8 5v14l11-7z\" />\n </svg>\n Run\n </button>\n <button class=\"btn\" id=\"builderSave\">Save</button>\n <button class=\"btn\" id=\"builderCopy\" title=\"Copy generated TypeScript to clipboard\">Copy TS</button>\n <button class=\"btn btn-ghost btn-sm\" id=\"builderReset\">Reset</button>\n </div>\n </div>\n <div class=\"builder-wrap\">\n <div class=\"builder-left\" id=\"builderLeft\"></div>\n <div class=\"builder-right\">\n <div class=\"builder-preview\" id=\"builderPreview\"></div>\n <div class=\"builder-results\">\n <div class=\"builder-results-header\" id=\"builderResultsHeader\">Results</div>\n <div class=\"data-scroll\" id=\"builderResults\" style=\"flex:1\"></div>\n </div>\n </div>\n </div>\n </section>\n </main>\n </div>\n </div>\n\n <!-- Modals -->\n <div class=\"modal-backdrop\" id=\"savePromptBackdrop\" aria-hidden=\"true\">\n <div class=\"modal\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"savePromptTitle\">\n <div class=\"modal-header\">\n <div class=\"modal-title\" id=\"savePromptTitle\">Save query</div>\n <button class=\"icon-btn\" data-close-modal=\"savePromptBackdrop\" aria-label=\"Close\">×</button>\n </div>\n <div class=\"modal-body\">\n <div class=\"modal-field\">\n <label for=\"savePromptName\">Name</label>\n <input id=\"savePromptName\" type=\"text\" placeholder=\"e.g. recent active users\" />\n </div>\n <div class=\"modal-field\">\n <label for=\"savePromptTable\">Target table</label>\n <select id=\"savePromptTable\"></select>\n </div>\n </div>\n <div class=\"modal-footer\">\n <button class=\"btn btn-ghost\" data-close-modal=\"savePromptBackdrop\">Cancel</button>\n <button class=\"btn btn-primary\" id=\"savePromptConfirm\">Save</button>\n </div>\n </div>\n </div>\n\n <div class=\"modal-backdrop\" id=\"jsonModalBackdrop\" aria-hidden=\"true\">\n <div class=\"modal\" role=\"dialog\" aria-modal=\"true\" style=\"width: 640px\">\n <div class=\"modal-header\">\n <div class=\"modal-title\">JSON value</div>\n <button class=\"icon-btn\" data-close-modal=\"jsonModalBackdrop\" aria-label=\"Close\">×</button>\n </div>\n <div class=\"modal-body\">\n <div class=\"json-view\" id=\"jsonModalBody\"></div>\n </div>\n </div>\n </div>\n\n <div class=\"modal-backdrop\" id=\"paletteBackdrop\" aria-hidden=\"true\">\n <div class=\"modal palette\" role=\"dialog\" aria-modal=\"true\">\n <input\n class=\"palette-input\"\n id=\"paletteInput\"\n type=\"text\"\n placeholder=\"Jump to table, run command…\"\n aria-label=\"Command palette input\"\n />\n <div class=\"palette-list\" id=\"paletteList\"></div>\n </div>\n </div>\n\n <div class=\"toast-wrap\" id=\"toastWrap\" aria-live=\"polite\"></div>\n\n <script>\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 // data pane\n data: {\n rows: [],\n columns: [],\n total: 0,\n limit: 50,\n offset: 0,\n orderBy: null,\n dir: 'asc',\n search: '',\n loading: false,\n error: null,\n },\n // builder (Query) pane\n builder: {\n table: null,\n args: emptyBuilderArgs(),\n result: null,\n error: null,\n running: false,\n validation: {},\n },\n savedQueries: [],\n sidebarFilter: '',\n // save prompt context (builder queries only)\n savePrompt: {\n kind: 'builder',\n args: null,\n },\n };\n\n function emptyBuilderArgs() {\n return {\n combinator: 'AND',\n where: [],\n with: [], // [{ relation, args: {...} }]\n orderBy: { column: null, dir: 'asc' },\n limit: 10,\n select: [],\n omit: [],\n };\n }\n\n // =====================================================================\n // Storage (localStorage wrapper)\n // =====================================================================\n const Storage = {\n PREFIX: 'turbine_studio_',\n get(key, fallback) {\n try {\n const raw = localStorage.getItem(Storage.PREFIX + key);\n if (raw == null) return fallback;\n return JSON.parse(raw);\n } catch {\n return fallback;\n }\n },\n set(key, val) {\n try {\n localStorage.setItem(Storage.PREFIX + key, JSON.stringify(val));\n } catch {\n /* ignore quota */\n }\n },\n del(key) {\n try {\n localStorage.removeItem(Storage.PREFIX + key);\n } catch {\n /* ignore */\n }\n },\n };\n\n // =====================================================================\n // API client\n // =====================================================================\n const Api = {\n async _fetch(path, opts = {}) {\n const res = await fetch(path, {\n credentials: 'same-origin',\n headers: { 'Content-Type': 'application/json' },\n ...opts,\n });\n let body = null;\n try {\n body = await res.json();\n } catch {\n /* non-JSON response */\n }\n if (!res.ok) {\n const err = new Error((body && body.error) || res.statusText || 'Request failed');\n err.status = res.status;\n throw err;\n }\n return body;\n },\n schema() {\n return Api._fetch('/api/schema');\n },\n rows(table, params) {\n const qs = new URLSearchParams();\n if (params) {\n for (const [k, v] of Object.entries(params)) {\n if (v !== undefined && v !== null && v !== '') qs.set(k, String(v));\n }\n }\n const q = qs.toString();\n return Api._fetch('/api/tables/' + encodeURIComponent(table) + (q ? '?' + q : ''));\n },\n runBuilder(table, args) {\n return Api._fetch('/api/builder', {\n method: 'POST',\n body: JSON.stringify({ table, args }),\n });\n },\n savedQueries(table) {\n const qs = table ? '?table=' + encodeURIComponent(table) : '';\n return Api._fetch('/api/saved-queries' + qs);\n },\n saveQuery(payload) {\n return Api._fetch('/api/saved-queries', {\n method: 'POST',\n body: JSON.stringify(payload),\n });\n },\n deleteSaved(id) {\n return Api._fetch('/api/saved-queries/' + encodeURIComponent(id), {\n method: 'DELETE',\n });\n },\n };\n\n // =====================================================================\n // Utilities\n // =====================================================================\n const $ = (sel, root) => (root || document).querySelector(sel);\n const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel));\n\n function el(tag, attrs = {}, children = []) {\n const node = document.createElement(tag);\n for (const [k, v] of Object.entries(attrs)) {\n if (k === 'class') node.className = v;\n else if (k === 'text') node.textContent = v;\n else if (k === 'html') node.innerHTML = v;\n else if (k.startsWith('on') && typeof v === 'function') {\n node.addEventListener(k.slice(2).toLowerCase(), v);\n } else if (k === 'style' && typeof v === 'object') {\n Object.assign(node.style, v);\n } else if (v !== undefined && v !== null && v !== false) {\n node.setAttribute(k, v === true ? '' : String(v));\n }\n }\n const list = Array.isArray(children) ? children : [children];\n for (const c of list) {\n if (c == null || c === false) continue;\n if (typeof c === 'string' || typeof c === 'number') {\n node.appendChild(document.createTextNode(String(c)));\n } else {\n node.appendChild(c);\n }\n }\n return node;\n }\n\n function escapeHtml(s) {\n if (s == null) return '';\n return String(s)\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n }\n\n function formatCount(n) {\n if (n == null) return '—';\n if (n < 1000) return String(n);\n if (n < 1_000_000) return (n / 1000).toFixed(n < 10_000 ? 1 : 0).replace(/\\.0$/, '') + 'k';\n if (n < 1_000_000_000) return (n / 1_000_000).toFixed(n < 10_000_000 ? 1 : 0).replace(/\\.0$/, '') + 'M';\n return (n / 1_000_000_000).toFixed(1).replace(/\\.0$/, '') + 'B';\n }\n\n function formatNumber(n) {\n if (n == null) return '—';\n return Number(n).toLocaleString();\n }\n\n function debounce(fn, ms) {\n let t;\n return (...args) => {\n clearTimeout(t);\n t = setTimeout(() => fn(...args), ms);\n };\n }\n\n function isTypingInInput() {\n const a = document.activeElement;\n if (!a) return false;\n const tag = a.tagName;\n return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || a.isContentEditable;\n }\n\n function isMac() {\n return navigator.platform.toLowerCase().includes('mac');\n }\n\n function toast(msg, kind) {\n const t = el('div', { class: 'toast ' + (kind || ''), text: msg });\n $('#toastWrap').appendChild(t);\n setTimeout(() => {\n t.style.transition = 'opacity 200ms';\n t.style.opacity = '0';\n setTimeout(() => t.remove(), 220);\n }, 2400);\n }\n\n async function copyToClipboard(text, label) {\n if (!text) {\n toast('Nothing to copy', 'err');\n return;\n }\n try {\n if (navigator.clipboard && navigator.clipboard.writeText) {\n await navigator.clipboard.writeText(text);\n } else {\n // Fallback for non-secure contexts / older browsers\n const ta = document.createElement('textarea');\n ta.value = text;\n ta.style.position = 'fixed';\n ta.style.left = '-9999px';\n document.body.appendChild(ta);\n ta.select();\n document.execCommand('copy');\n document.body.removeChild(ta);\n }\n toast((label || 'Copied') + ' to clipboard', 'ok');\n } catch (err) {\n toast('Copy failed: ' + (err && err.message ? err.message : 'unknown'), 'err');\n }\n }\n\n function isStringType(ts) {\n return ts === 'string';\n }\n function isNumericType(ts) {\n return ts === 'number' || ts === 'bigint';\n }\n function isBoolType(ts) {\n return ts === 'boolean';\n }\n function isDateType(ts) {\n return ts === 'Date';\n }\n\n function operatorsForType(ts) {\n const base = ['equals', 'not', 'in', 'notIn'];\n if (isStringType(ts)) return base.concat(['contains', 'startsWith', 'endsWith']);\n if (isNumericType(ts) || isDateType(ts)) return base.concat(['lt', 'lte', 'gt', 'gte']);\n return base;\n }\n\n // =====================================================================\n // Sidebar\n // =====================================================================\n const Sidebar = {\n render() {\n const tablesList = $('#tablesList');\n const enumsList = $('#enumsList');\n tablesList.innerHTML = '';\n enumsList.innerHTML = '';\n\n const filter = State.sidebarFilter.toLowerCase();\n const filtered = State.tables.filter((t) =>\n !filter || t.name.toLowerCase().includes(filter)\n );\n\n if (!filtered.length) {\n tablesList.appendChild(el('div', { class: 'sidebar-empty', text: 'No tables' }));\n } else {\n for (const t of filtered) {\n const active = State.currentTable && State.currentTable.name === t.name;\n const item = el(\n 'div',\n {\n class: 'sidebar-item' + (active ? ' active' : ''),\n role: 'button',\n tabindex: '0',\n 'aria-label': 'Table ' + t.name,\n onclick: () => Router.selectTable(t.name),\n onkeydown: (e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n Router.selectTable(t.name);\n }\n },\n },\n [\n el('span', { class: 'sidebar-item-name', text: t.name }),\n el('span', { class: 'sidebar-item-count', text: formatCount(t.estimatedRows) }),\n ]\n );\n tablesList.appendChild(item);\n }\n }\n\n const enumEntries = Object.entries(State.enums || {});\n if (!enumEntries.length) {\n enumsList.appendChild(el('div', { class: 'sidebar-empty', text: 'No enums' }));\n } else {\n for (const [name, values] of enumEntries) {\n enumsList.appendChild(\n el(\n 'div',\n {\n class: 'sidebar-item',\n title: (values || []).join(', '),\n },\n [\n el('span', { class: 'sidebar-item-name', text: name }),\n el('span', { class: 'sidebar-item-count', text: String((values || []).length) }),\n ]\n )\n );\n }\n }\n\n Sidebar.renderSaved();\n },\n\n renderSaved() {\n const host = $('#savedList');\n host.innerHTML = '';\n if (!State.savedQueries.length) {\n host.appendChild(el('div', { class: 'sidebar-empty', text: 'No saved queries' }));\n return;\n }\n const byTable = new Map();\n for (const q of State.savedQueries) {\n if (!byTable.has(q.table)) byTable.set(q.table, []);\n byTable.get(q.table).push(q);\n }\n const ordered = Array.from(byTable.keys()).sort();\n for (const table of ordered) {\n host.appendChild(el('div', { class: 'saved-table-header', text: table }));\n for (const q of byTable.get(table)) {\n const row = el('div', { class: 'saved-query', onclick: () => SavedQueries.load(q) }, [\n el('span', { class: 'saved-query-name', text: q.name, title: q.name }),\n el('span', { class: 'saved-kind', text: q.kind }),\n el('button', {\n class: 'saved-query-del icon-btn',\n 'aria-label': 'Delete saved query',\n onclick: (e) => {\n e.stopPropagation();\n SavedQueries.remove(q.id);\n },\n text: '×',\n }),\n ]);\n host.appendChild(row);\n }\n }\n },\n\n bind() {\n $('#sidebarSearch').addEventListener('input', (e) => {\n State.sidebarFilter = e.target.value;\n Sidebar.render();\n });\n $$('.sidebar-group-header').forEach((h) => {\n h.addEventListener('click', () => {\n const g = document.getElementById(h.dataset.group);\n if (g) g.classList.toggle('collapsed');\n });\n });\n\n // Resizer\n const resizer = $('#sidebarResizer');\n const sidebar = $('#sidebar');\n let startX = 0;\n let startW = 0;\n const onMove = (e) => {\n const dx = e.clientX - startX;\n const next = Math.min(480, Math.max(200, startW + dx));\n document.documentElement.style.setProperty('--sidebar-w', next + 'px');\n };\n const onUp = () => {\n resizer.classList.remove('dragging');\n document.removeEventListener('mousemove', onMove);\n document.removeEventListener('mouseup', onUp);\n document.body.style.userSelect = '';\n const cur = getComputedStyle(document.documentElement).getPropertyValue('--sidebar-w');\n Storage.set('sidebarW', cur.trim());\n };\n resizer.addEventListener('mousedown', (e) => {\n startX = e.clientX;\n startW = sidebar.getBoundingClientRect().width;\n resizer.classList.add('dragging');\n document.body.style.userSelect = 'none';\n document.addEventListener('mousemove', onMove);\n document.addEventListener('mouseup', onUp);\n });\n\n $('#hamburger').addEventListener('click', () => {\n sidebar.classList.toggle('open');\n });\n },\n };\n\n // =====================================================================\n // Router / tabs\n // =====================================================================\n const Router = {\n selectTab(tab) {\n State.currentTab = tab;\n Storage.set('tab', tab);\n $$('.tab').forEach((b) =>\n b.classList.toggle('active', b.dataset.tab === tab)\n );\n $$('.pane').forEach((p) =>\n p.classList.toggle('active', p.id === 'pane-' + tab)\n );\n if (tab === 'schema') SchemaPane.render();\n if (tab === 'data') DataPane.render();\n if (tab === 'builder') BuilderPane.render();\n },\n\n selectTable(name) {\n const t = State.tablesByName.get(name);\n if (!t) return;\n State.currentTable = t;\n State.data.offset = 0;\n State.data.orderBy = (t.primaryKey && t.primaryKey[0]) || (t.columns[0] && t.columns[0].name);\n State.data.dir = 'asc';\n State.data.search = '';\n Storage.set('currentTable', name);\n Sidebar.render();\n if (State.currentTab === 'data') DataPane.loadAndRender();\n if (State.currentTab === 'schema') SchemaPane.render();\n if (State.currentTab === 'builder') {\n BuilderPane.resetForTable(name);\n }\n // always close mobile sidebar after selecting\n $('#sidebar').classList.remove('open');\n },\n\n bind() {\n $$('.tab').forEach((btn) => {\n btn.addEventListener('click', () => Router.selectTab(btn.dataset.tab));\n });\n },\n };\n\n // =====================================================================\n // Data pane\n // =====================================================================\n const DataPane = {\n render() {\n const t = State.currentTable;\n if (!t) {\n $('#dataTitle').textContent = '—';\n $('#dataTotal').textContent = '';\n $('#dataScroll').innerHTML = '<div class=\"empty\">Select a table to browse data.</div>';\n $('#dataRange').textContent = '—';\n $('#pageIndicator').textContent = '—';\n return;\n }\n $('#dataTitle').textContent = t.name;\n $('#dataTotal').textContent = State.data.loading ? 'loading…' : formatNumber(State.data.total) + ' rows';\n\n const host = $('#dataScroll');\n\n if (State.data.error) {\n host.innerHTML = '';\n host.appendChild(\n el('div', { class: 'error-box' }, [\n el('div', { class: 'error-box-title', text: 'Error loading data' }),\n document.createTextNode(State.data.error),\n ])\n );\n $('#dataRange').textContent = '—';\n $('#pageIndicator').textContent = '—';\n return;\n }\n\n if (State.data.loading) {\n host.innerHTML = '';\n const skel = el('div');\n for (let i = 0; i < 12; i++) {\n const row = el('div', { class: 'skeleton-row' });\n for (let c = 0; c < 5; c++) {\n row.appendChild(el('div', { class: 'skeleton-cell' }));\n }\n skel.appendChild(row);\n }\n host.appendChild(skel);\n return;\n }\n\n if (!State.data.rows.length) {\n host.innerHTML = '';\n host.appendChild(\n el('div', { class: 'empty' }, [\n el(\n 'svg',\n {\n class: 'empty-icon',\n viewBox: '0 0 24 24',\n fill: 'none',\n stroke: 'currentColor',\n 'stroke-width': '2',\n },\n []\n ),\n document.createTextNode(\n State.data.search ? 'No rows match your search.' : 'No rows.'\n ),\n ])\n );\n $('#dataRange').textContent = '0 of 0';\n $('#pageIndicator').textContent = '—';\n return;\n }\n\n host.innerHTML = '';\n const cols = State.data.columns.length\n ? State.data.columns.map((c) => c.name)\n : Object.keys(State.data.rows[0] || {});\n host.appendChild(Table.render(cols, State.data.rows, {\n sortable: true,\n orderBy: State.data.orderBy,\n dir: State.data.dir,\n onSort: (col) => {\n if (State.data.orderBy === col) {\n State.data.dir = State.data.dir === 'asc' ? 'desc' : 'asc';\n } else {\n State.data.orderBy = col;\n State.data.dir = 'asc';\n }\n State.data.offset = 0;\n DataPane.loadAndRender();\n },\n }));\n\n const start = State.data.total ? State.data.offset + 1 : 0;\n const end = Math.min(State.data.offset + State.data.limit, State.data.total);\n $('#dataRange').textContent = start + '-' + end + ' of ' + formatNumber(State.data.total);\n const page = Math.floor(State.data.offset / State.data.limit) + 1;\n const pages = Math.max(1, Math.ceil(State.data.total / State.data.limit));\n $('#pageIndicator').textContent = 'Page ' + page + ' / ' + pages;\n $('#pagePrev').disabled = State.data.offset === 0;\n $('#pageNext').disabled = State.data.offset + State.data.limit >= State.data.total;\n },\n\n async loadAndRender() {\n const t = State.currentTable;\n if (!t) return;\n State.data.loading = true;\n State.data.error = null;\n DataPane.render();\n try {\n const params = {\n limit: State.data.limit,\n offset: State.data.offset,\n orderBy: State.data.orderBy,\n dir: State.data.dir,\n search: State.data.search,\n };\n const res = await Api.rows(t.name, params);\n State.data.rows = res.rows || [];\n State.data.columns = res.columns || [];\n State.data.total = res.total || 0;\n } catch (err) {\n State.data.error = err.message;\n State.data.rows = [];\n State.data.total = 0;\n } finally {\n State.data.loading = false;\n DataPane.render();\n }\n },\n\n bind() {\n $('#dataSearch').addEventListener(\n 'input',\n debounce((e) => {\n State.data.search = e.target.value;\n State.data.offset = 0;\n DataPane.loadAndRender();\n }, 240)\n );\n $('#dataRefresh').addEventListener('click', () => DataPane.loadAndRender());\n $('#pagePrev').addEventListener('click', () => {\n if (State.data.offset === 0) return;\n State.data.offset = Math.max(0, State.data.offset - State.data.limit);\n DataPane.loadAndRender();\n });\n $('#pageNext').addEventListener('click', () => {\n if (State.data.offset + State.data.limit >= State.data.total) return;\n State.data.offset += State.data.limit;\n DataPane.loadAndRender();\n });\n },\n };\n\n // =====================================================================\n // Shared data table renderer\n // =====================================================================\n const Table = {\n render(cols, rows, opts = {}) {\n const table = el('table', { class: 'data' });\n const thead = el('thead');\n const headRow = el('tr');\n for (const c of cols) {\n const attrs = {\n class: opts.sortable ? 'sortable' : '',\n scope: 'col',\n };\n if (opts.sortable) {\n attrs.onclick = () => opts.onSort && opts.onSort(c);\n }\n const th = el('th', attrs);\n th.appendChild(document.createTextNode(c));\n if (opts.sortable && opts.orderBy === c) {\n th.appendChild(el('span', { class: 'sort-ind', text: opts.dir === 'asc' ? '▲' : '▼' }));\n }\n headRow.appendChild(th);\n }\n thead.appendChild(headRow);\n table.appendChild(thead);\n\n const tbody = el('tbody');\n for (const row of rows) {\n const tr = el('tr');\n for (const c of cols) {\n const val = row[c];\n tr.appendChild(Table.renderCell(val));\n }\n tbody.appendChild(tr);\n }\n table.appendChild(tbody);\n return table;\n },\n\n renderCell(val) {\n const td = el('td');\n if (val === null || val === undefined) {\n td.className = 'cell-null';\n td.textContent = 'null';\n td.title = 'null';\n return td;\n }\n if (typeof val === 'number') {\n td.className = 'cell-number';\n td.textContent = String(val);\n td.title = String(val);\n return td;\n }\n if (typeof val === 'boolean') {\n td.className = 'cell-bool';\n td.textContent = String(val);\n td.title = String(val);\n return td;\n }\n if (val && typeof val === 'object') {\n td.className = 'cell-json';\n const str = JSON.stringify(val);\n const preview = str.length > 48 ? str.slice(0, 45) + '…' : str;\n td.textContent = preview;\n td.title = 'JSON (click to expand)';\n const btn = el('button', {\n class: 'expand-btn',\n text: 'expand',\n onclick: (e) => {\n e.stopPropagation();\n JsonModal.open(val);\n },\n });\n td.appendChild(btn);\n return td;\n }\n const s = String(val);\n td.textContent = s;\n td.title = s;\n return td;\n },\n };\n\n // =====================================================================\n // Schema pane\n // =====================================================================\n const SchemaPane = {\n render() {\n const t = State.currentTable;\n const host = $('#schemaScroll');\n if (!t) {\n $('#schemaTitle').textContent = '—';\n $('#schemaSub').textContent = '';\n host.innerHTML = '<div class=\"empty\">Select a table to inspect its schema.</div>';\n return;\n }\n $('#schemaTitle').textContent = t.name;\n $('#schemaSub').textContent =\n (t.columns.length + ' columns · ' + (t.relations || []).length + ' relations');\n host.innerHTML = '';\n\n // Columns\n const colsSection = el('section', { class: 'schema-section' });\n colsSection.appendChild(el('h2', { text: 'Columns' }));\n const colTable = el('table', { class: 'col-table' });\n const colHead = el('thead');\n colHead.appendChild(\n el('tr', {}, ['Name', 'TS', 'PG', 'Attributes'].map((h) => el('th', { text: h })))\n );\n colTable.appendChild(colHead);\n const colBody = el('tbody');\n for (const c of t.columns) {\n const tr = el('tr');\n tr.appendChild(\n el('td', {}, [\n el('span', {\n class: 'col-name' + (c.isPrimaryKey ? ' pk' : ''),\n text: c.name,\n }),\n ])\n );\n tr.appendChild(el('td', {}, [el('span', { class: 'col-ts', text: c.tsType || '—' })]));\n tr.appendChild(el('td', {}, [el('span', { class: 'col-pg', text: c.pgType || '—' })]));\n const attrs = el('td');\n if (c.isPrimaryKey) attrs.appendChild(el('span', { class: 'badge pk', text: 'PK' }));\n if (!c.nullable) attrs.appendChild(el('span', { class: 'badge nn', text: 'NOT NULL' }));\n else attrs.appendChild(el('span', { class: 'badge nullable', text: 'nullable' }));\n if (c.hasDefault) attrs.appendChild(el('span', { class: 'badge def', text: 'default' }));\n tr.appendChild(attrs);\n colBody.appendChild(tr);\n }\n colTable.appendChild(colBody);\n colsSection.appendChild(colTable);\n host.appendChild(colsSection);\n\n // Relations\n const rels = t.relations || [];\n if (rels.length) {\n const relsSection = el('section', { class: 'schema-section' });\n relsSection.appendChild(el('h2', { text: 'Relations' }));\n const relTable = el('table', { class: 'col-table' });\n const relHead = el('thead');\n relHead.appendChild(\n el('tr', {}, ['Name', 'Type', 'Target', 'Keys'].map((h) => el('th', { text: h })))\n );\n relTable.appendChild(relHead);\n const relBody = el('tbody');\n for (const r of rels) {\n const tr = el('tr');\n tr.appendChild(el('td', {}, [el('span', { class: 'col-rel-name', text: r.name })]));\n tr.appendChild(el('td', {}, [el('span', { class: 'badge rel-type', text: r.type })]));\n tr.appendChild(el('td', {}, [el('span', { class: 'col-name', text: r.to })]));\n tr.appendChild(\n el('td', {}, [\n el('span', {\n class: 'col-pg',\n text: (r.foreignKey || '—') + ' → ' + (r.referenceKey || '—'),\n }),\n ])\n );\n relBody.appendChild(tr);\n }\n relTable.appendChild(relBody);\n relsSection.appendChild(relTable);\n host.appendChild(relsSection);\n }\n\n // CREATE TABLE\n const details = el('details', { class: 'schema-details' });\n details.appendChild(el('summary', { text: 'CREATE TABLE SQL' }));\n const pre = el('pre');\n pre.innerHTML = SchemaPane.renderCreateTable(t);\n details.appendChild(pre);\n host.appendChild(details);\n },\n\n renderCreateTable(t) {\n const lines = [];\n lines.push(\n '<span class=\"sql-kw\">CREATE TABLE</span> <span class=\"sql-ident\">\"' +\n escapeHtml(t.name) +\n '\"</span> ('\n );\n const colLines = t.columns.map((c) => {\n let line = ' <span class=\"sql-ident\">\"' + escapeHtml(c.name) + '\"</span> ';\n line += '<span class=\"sql-type\">' + escapeHtml(c.pgType || 'text') + '</span>';\n if (!c.nullable) line += ' <span class=\"sql-kw\">NOT NULL</span>';\n if (c.hasDefault) line += ' <span class=\"sql-kw\">DEFAULT</span> <span class=\"sql-str\">…</span>';\n return line;\n });\n if (t.primaryKey && t.primaryKey.length) {\n colLines.push(\n ' <span class=\"sql-kw\">PRIMARY KEY</span> (' +\n t.primaryKey.map((k) => '<span class=\"sql-ident\">\"' + escapeHtml(k) + '\"</span>').join(', ') +\n ')'\n );\n }\n lines.push(colLines.join(',\\n'));\n lines.push(');');\n return lines.join('\\n');\n },\n };\n\n // =====================================================================\n // Builder pane\n // =====================================================================\n const BuilderPane = {\n resetForTable(tableName) {\n State.builder.table = tableName;\n State.builder.args = emptyBuilderArgs();\n State.builder.result = null;\n State.builder.error = null;\n State.builder.validation = {};\n BuilderPane.render();\n },\n\n findTable(name) {\n return State.tablesByName.get(name);\n },\n\n render() {\n const host = $('#builderLeft');\n host.innerHTML = '';\n if (!State.tables.length) {\n host.appendChild(el('div', { class: 'empty', text: 'Loading schema…' }));\n BuilderPane.updatePreview();\n return;\n }\n if (!State.builder.table) {\n State.builder.table =\n (State.currentTable && State.currentTable.name) ||\n (State.tables[0] && State.tables[0].name);\n }\n $('#builderSub').textContent = 'Visual findMany composer';\n\n // Table picker\n const tablePick = el('div', { class: 'builder-section' }, [\n el('div', { class: 'builder-section-title' }, [el('h3', { text: 'Table' })]),\n (() => {\n const sel = el('select', { class: 'builder-select', id: 'builderTable' });\n for (const t of State.tables) {\n sel.appendChild(el('option', { value: t.name, text: t.name }));\n }\n sel.value = State.builder.table;\n sel.addEventListener('change', () => {\n BuilderPane.resetForTable(sel.value);\n });\n return sel;\n })(),\n ]);\n host.appendChild(tablePick);\n\n const tableMeta = BuilderPane.findTable(State.builder.table);\n if (!tableMeta) {\n host.appendChild(el('div', { class: 'error-box', text: 'Unknown table.' }));\n return;\n }\n\n // WHERE\n host.appendChild(BuilderPane.renderWhere(State.builder.args, tableMeta, []));\n // WITH\n host.appendChild(BuilderPane.renderWith(State.builder.args, tableMeta, []));\n // ORDER BY / LIMIT\n host.appendChild(BuilderPane.renderOrderLimit(State.builder.args, tableMeta));\n // SELECT / OMIT\n host.appendChild(BuilderPane.renderSelectOmit(State.builder.args, tableMeta));\n\n BuilderPane.updatePreview();\n BuilderPane.updateRunButton();\n },\n\n renderWhere(args, tableMeta, path) {\n const section = el('div', { class: 'builder-section' });\n const header = el('div', { class: 'builder-section-title' }, [\n el('h3', { text: 'where' }),\n (() => {\n const pick = el('div', { class: 'combinator-pick' });\n for (const c of ['AND', 'OR', 'NOT']) {\n pick.appendChild(\n el('button', {\n class: args.combinator === c ? 'active' : '',\n text: c,\n onclick: () => {\n args.combinator = c;\n BuilderPane.render();\n },\n })\n );\n }\n return pick;\n })(),\n ]);\n section.appendChild(header);\n\n const wrap = el('div', { class: 'where-clauses' });\n args.where.forEach((clause, idx) => {\n wrap.appendChild(BuilderPane.renderClause(args, clause, idx, tableMeta));\n });\n section.appendChild(wrap);\n\n const addRow = el('div', { class: 'clause-row', style: { marginTop: '8px' } }, [\n el('button', {\n class: 'btn btn-sm',\n text: '+ Clause',\n onclick: () => {\n args.where.push({\n column: (tableMeta.columns[0] || {}).name || '',\n op: 'equals',\n value: '',\n insensitive: false,\n });\n BuilderPane.render();\n },\n }),\n ]);\n section.appendChild(addRow);\n\n return section;\n },\n\n renderClause(args, clause, idx, tableMeta) {\n const row = el('div', { class: 'where-clause' });\n // column select\n const colSel = el('select', { class: 'builder-select' });\n for (const c of tableMeta.columns) {\n const opt = el('option', { value: c.name, text: c.name });\n if (c.name === clause.column) opt.selected = true;\n colSel.appendChild(opt);\n }\n colSel.addEventListener('change', () => {\n clause.column = colSel.value;\n const col = tableMeta.columns.find((c) => c.name === clause.column);\n const ops = col ? operatorsForType(col.tsType) : ['equals'];\n if (!ops.includes(clause.op)) clause.op = ops[0];\n BuilderPane.render();\n });\n row.appendChild(colSel);\n\n // op select\n const col = tableMeta.columns.find((c) => c.name === clause.column);\n const ops = col ? operatorsForType(col.tsType) : ['equals'];\n const opSel = el('select', { class: 'builder-select' });\n for (const op of ops) {\n const opt = el('option', { value: op, text: op });\n if (op === clause.op) opt.selected = true;\n opSel.appendChild(opt);\n }\n opSel.addEventListener('change', () => {\n clause.op = opSel.value;\n BuilderPane.render();\n });\n row.appendChild(opSel);\n\n // value input\n const valInput = el('input', {\n class: 'builder-input',\n type: col && isNumericType(col.tsType) ? 'number' : 'text',\n placeholder: 'value',\n value: clause.value != null ? String(clause.value) : '',\n });\n valInput.addEventListener('input', () => {\n clause.value = valInput.value;\n BuilderPane.updatePreview();\n BuilderPane.updateRunButton();\n });\n row.appendChild(valInput);\n\n // remove\n const rm = el('button', {\n class: 'btn-ghost icon-btn',\n 'aria-label': 'Remove clause',\n text: '×',\n onclick: () => {\n args.where.splice(idx, 1);\n BuilderPane.render();\n },\n });\n row.appendChild(rm);\n\n // insensitive toggle for string cols\n if (col && isStringType(col.tsType) && ['contains', 'startsWith', 'endsWith', 'equals'].includes(clause.op)) {\n const ci = el('div', { class: 'clause-row', style: { gridColumn: '1 / -1' } }, [\n el(\n 'button',\n {\n class: 'chip' + (clause.insensitive ? ' active' : ''),\n text: 'mode: insensitive',\n onclick: () => {\n clause.insensitive = !clause.insensitive;\n BuilderPane.render();\n },\n },\n []\n ),\n ]);\n const both = el('div', { class: 'builder-field' });\n both.appendChild(row);\n both.appendChild(ci);\n return both;\n }\n return row;\n },\n\n renderWith(args, tableMeta, path) {\n const section = el('div', { class: 'builder-section' });\n section.appendChild(\n el('div', { class: 'builder-section-title' }, [el('h3', { text: 'with (relations)' })])\n );\n const rels = tableMeta.relations || [];\n // Depth guard — the engine caps `with` nesting at depth 10\n // (CircularRelationError). Stop offering deeper expansion before then so\n // the UI can never compose a query the engine will reject.\n if (path.length >= 8) {\n section.appendChild(\n el('div', { class: 'field-hint', style: { color: 'var(--text-muted)' }, text: 'Max relation depth reached.' })\n );\n return section;\n }\n if (!rels.length) {\n section.appendChild(\n el('div', { class: 'field-hint', style: { color: 'var(--text-muted)' }, text: 'No relations.' })\n );\n return section;\n }\n const wrap = el('div', { class: 'with-rels' });\n for (const rel of rels) {\n const existing = args.with.find((w) => w.relation === rel.name);\n const active = !!existing;\n const relBox = el('div', { class: 'with-rel' + (existing && existing.expanded ? ' expanded' : '') });\n const head = el('div', { class: 'with-rel-header' }, [\n el('div', {}, [\n el('span', { class: 'chev', text: '›' }),\n el('span', { class: 'with-rel-name', text: ' ' + rel.name }),\n el('span', { class: 'with-rel-meta', text: ' · ' + rel.type + ' → ' + rel.to }),\n ]),\n el(\n 'button',\n {\n class: 'chip' + (active ? ' active' : ''),\n text: active ? 'included' : 'include',\n onclick: (e) => {\n e.stopPropagation();\n if (active) {\n args.with = args.with.filter((w) => w.relation !== rel.name);\n } else {\n args.with.push({\n relation: rel.name,\n expanded: true,\n args: emptyBuilderArgs(),\n });\n }\n BuilderPane.render();\n },\n },\n []\n ),\n ]);\n head.addEventListener('click', () => {\n if (existing) {\n existing.expanded = !existing.expanded;\n BuilderPane.render();\n }\n });\n relBox.appendChild(head);\n\n if (existing && existing.expanded) {\n const targetMeta = BuilderPane.findTable(rel.to);\n const body = el('div', { class: 'with-rel-body' });\n if (!targetMeta) {\n body.appendChild(el('div', { class: 'field-hint', text: 'Unknown target table ' + rel.to }));\n } else {\n // Same controls as the top level, applied to this relation's args —\n // this is the recursive drill-down: pick its fields, filter it, order\n // it, and expand its own relations to arbitrary depth.\n // orderBy/limit only apply to to-many relations; a belongsTo/hasOne\n // always resolves to a single row, so we hide those controls for it.\n const toMany = rel.type === 'hasMany' || rel.type === 'manyToMany';\n body.appendChild(BuilderPane.renderSelectOmit(existing.args, targetMeta));\n body.appendChild(BuilderPane.renderWhere(existing.args, targetMeta, path.concat(rel.name)));\n if (toMany) body.appendChild(BuilderPane.renderOrderLimit(existing.args, targetMeta));\n body.appendChild(BuilderPane.renderWith(existing.args, targetMeta, path.concat(rel.name)));\n }\n relBox.appendChild(body);\n }\n wrap.appendChild(relBox);\n }\n section.appendChild(wrap);\n return section;\n },\n\n renderOrderLimit(args, tableMeta) {\n const section = el('div', { class: 'builder-section' });\n section.appendChild(\n el('div', { class: 'builder-section-title' }, [el('h3', { text: 'orderBy / limit' })])\n );\n\n const row = el('div', { style: { display: 'grid', gridTemplateColumns: '1fr 120px 100px', gap: '8px' } });\n\n const colSel = el('select', { class: 'builder-select' });\n colSel.appendChild(el('option', { value: '', text: '— none —' }));\n for (const c of tableMeta.columns) {\n const opt = el('option', { value: c.name, text: c.name });\n if (args.orderBy && args.orderBy.column === c.name) opt.selected = true;\n colSel.appendChild(opt);\n }\n colSel.addEventListener('change', () => {\n args.orderBy = args.orderBy || { column: null, dir: 'asc' };\n args.orderBy.column = colSel.value || null;\n BuilderPane.updatePreview();\n });\n row.appendChild(colSel);\n\n const dirSel = el('select', { class: 'builder-select' });\n for (const d of ['asc', 'desc']) {\n const opt = el('option', { value: d, text: d });\n if (args.orderBy && args.orderBy.dir === d) opt.selected = true;\n dirSel.appendChild(opt);\n }\n dirSel.addEventListener('change', () => {\n args.orderBy = args.orderBy || { column: null, dir: 'asc' };\n args.orderBy.dir = dirSel.value;\n BuilderPane.updatePreview();\n });\n row.appendChild(dirSel);\n\n const limInput = el('input', {\n class: 'builder-input',\n type: 'number',\n min: '1',\n max: '10000',\n value: args.limit != null ? String(args.limit) : '',\n placeholder: 'limit',\n });\n limInput.addEventListener('input', () => {\n const n = parseInt(limInput.value, 10);\n args.limit = Number.isFinite(n) && n > 0 ? n : null;\n BuilderPane.updatePreview();\n BuilderPane.updateRunButton();\n });\n row.appendChild(limInput);\n section.appendChild(row);\n return section;\n },\n\n renderSelectOmit(args, tableMeta) {\n const section = el('div', { class: 'builder-section' });\n section.appendChild(\n el('div', { class: 'builder-section-title' }, [el('h3', { text: 'select / omit' })])\n );\n const chipsSel = el('div', { class: 'chip-row' });\n chipsSel.appendChild(\n el(\n 'span',\n { style: { fontFamily: 'var(--mono)', fontSize: '10px', color: 'var(--text-muted)', marginRight: '4px' } },\n ['select:']\n )\n );\n for (const c of tableMeta.columns) {\n const active = args.select.includes(c.name);\n chipsSel.appendChild(\n el('button', {\n class: 'chip' + (active ? ' active' : ''),\n text: c.name,\n onclick: () => {\n if (active) args.select = args.select.filter((x) => x !== c.name);\n else {\n args.select.push(c.name);\n args.omit = args.omit.filter((x) => x !== c.name);\n }\n BuilderPane.render();\n },\n })\n );\n }\n section.appendChild(chipsSel);\n\n const chipsOm = el('div', { class: 'chip-row', style: { marginTop: '8px' } });\n chipsOm.appendChild(\n el(\n 'span',\n { style: { fontFamily: 'var(--mono)', fontSize: '10px', color: 'var(--text-muted)', marginRight: '4px' } },\n ['omit:']\n )\n );\n for (const c of tableMeta.columns) {\n const active = args.omit.includes(c.name);\n chipsOm.appendChild(\n el('button', {\n class: 'chip' + (active ? ' active' : ''),\n text: c.name,\n onclick: () => {\n if (active) args.omit = args.omit.filter((x) => x !== c.name);\n else {\n args.omit.push(c.name);\n args.select = args.select.filter((x) => x !== c.name);\n }\n BuilderPane.render();\n },\n })\n );\n }\n section.appendChild(chipsOm);\n return section;\n },\n\n // ---------- Spec assembly / validation ----------\n buildFindManyArgs(localArgs, tableMeta) {\n const out = {};\n const whereObj = BuilderPane.assembleWhere(localArgs, tableMeta);\n if (whereObj) out.where = whereObj;\n if (localArgs.with && localArgs.with.length) {\n out.with = {};\n for (const w of localArgs.with) {\n const rel = (tableMeta.relations || []).find((r) => r.name === w.relation);\n if (!rel) continue;\n const childMeta = BuilderPane.findTable(rel.to);\n const childArgs = childMeta ? BuilderPane.buildFindManyArgs(w.args, childMeta) : null;\n if (childArgs && Object.keys(childArgs).length) {\n out.with[w.relation] = childArgs;\n } else {\n out.with[w.relation] = true;\n }\n }\n }\n if (localArgs.orderBy && localArgs.orderBy.column) {\n out.orderBy = { [localArgs.orderBy.column]: localArgs.orderBy.dir };\n }\n if (localArgs.limit) out.limit = localArgs.limit;\n if (localArgs.select && localArgs.select.length) {\n out.select = Object.fromEntries(localArgs.select.map((c) => [c, true]));\n }\n if (localArgs.omit && localArgs.omit.length) {\n out.omit = Object.fromEntries(localArgs.omit.map((c) => [c, true]));\n }\n return out;\n },\n\n assembleWhere(localArgs, tableMeta) {\n if (!localArgs.where || !localArgs.where.length) return null;\n const clauses = [];\n for (const c of localArgs.where) {\n const col = tableMeta.columns.find((cc) => cc.name === c.column);\n if (!col) continue;\n const parsed = BuilderPane.coerceValue(c.value, col.tsType, c.op);\n if (parsed === undefined) continue;\n const inner =\n c.op === 'equals'\n ? parsed\n : c.insensitive\n ? { [c.op]: parsed, mode: 'insensitive' }\n : { [c.op]: parsed };\n clauses.push({ [c.column]: inner });\n }\n if (!clauses.length) return null;\n if (clauses.length === 1 && localArgs.combinator === 'AND') return clauses[0];\n if (localArgs.combinator === 'AND') return Object.assign({}, ...clauses);\n if (localArgs.combinator === 'OR') return { OR: clauses };\n if (localArgs.combinator === 'NOT') return { NOT: clauses.length === 1 ? clauses[0] : { AND: clauses } };\n return null;\n },\n\n coerceValue(val, tsType, op) {\n if (val == null || val === '') return undefined;\n if (op === 'in' || op === 'notIn') {\n return String(val)\n .split(',')\n .map((s) => s.trim())\n .filter(Boolean)\n .map((s) => (isNumericType(tsType) ? Number(s) : s));\n }\n if (isNumericType(tsType)) {\n const n = Number(val);\n return Number.isFinite(n) ? n : undefined;\n }\n if (isBoolType(tsType)) {\n return val === 'true' || val === true;\n }\n return String(val);\n },\n\n validate() {\n const errs = {};\n if (!State.builder.table) errs.table = 'Pick a table';\n if (State.builder.args.limit != null && State.builder.args.limit < 1) {\n errs.limit = 'Limit must be ≥ 1';\n }\n for (const c of State.builder.args.where) {\n if (!c.column) errs.where = 'Clause missing column';\n if (c.value === '' || c.value == null) errs.where = 'Clause missing value';\n }\n State.builder.validation = errs;\n return Object.keys(errs).length === 0;\n },\n\n updateRunButton() {\n const ok = BuilderPane.validate();\n $('#builderRun').disabled = !ok;\n },\n\n updatePreview() {\n const host = $('#builderPreview');\n const tableMeta = BuilderPane.findTable(State.builder.table);\n if (!tableMeta) {\n host.textContent = '';\n host.dataset.rawCode = '';\n return;\n }\n const args = BuilderPane.buildFindManyArgs(State.builder.args, tableMeta);\n const code = BuilderPane.renderCode(State.builder.table, args);\n host.dataset.rawCode = code;\n host.innerHTML = highlightTs(code);\n },\n\n renderCode(tableName, args) {\n const ind = ' ';\n let out = 'await client.' + tableName + '.findMany(';\n const body = renderJsArgs(args, 1);\n if (!body) {\n out += ');';\n } else {\n out += '{\\n' + body + '\\n});';\n }\n return out;\n },\n\n async run() {\n if (!BuilderPane.validate()) return;\n const tableMeta = BuilderPane.findTable(State.builder.table);\n const args = BuilderPane.buildFindManyArgs(State.builder.args, tableMeta);\n State.builder.running = true;\n State.builder.error = null;\n BuilderPane.renderResults();\n try {\n const res = await Api.runBuilder(State.builder.table, args);\n State.builder.result = res;\n State.builder.error = null;\n } catch (err) {\n State.builder.error = err.message;\n State.builder.result = null;\n } finally {\n State.builder.running = false;\n BuilderPane.renderResults();\n }\n },\n\n renderResults() {\n const host = $('#builderResults');\n const header = $('#builderResultsHeader');\n if (State.builder.running) {\n header.textContent = 'Results · running…';\n host.innerHTML = '';\n const skel = el('div');\n for (let i = 0; i < 6; i++) {\n const row = el('div', { class: 'skeleton-row' });\n for (let c = 0; c < 4; c++) row.appendChild(el('div', { class: 'skeleton-cell' }));\n skel.appendChild(row);\n }\n host.appendChild(skel);\n return;\n }\n if (State.builder.error) {\n header.textContent = 'Results · error';\n host.innerHTML = '';\n host.appendChild(\n el('div', { class: 'error-box' }, [\n el('div', { class: 'error-box-title', text: 'Builder error' }),\n document.createTextNode(State.builder.error),\n ])\n );\n return;\n }\n const r = State.builder.result;\n if (!r) {\n header.textContent = 'Results';\n host.innerHTML = '<div class=\"empty\">Run to see results.</div>';\n return;\n }\n header.textContent = 'Results · ' + formatNumber(r.rowCount) + ' rows · ' + r.elapsedMs + 'ms';\n host.innerHTML = '';\n if (!r.rows || !r.rows.length) {\n host.appendChild(el('div', { class: 'empty', text: 'No rows.' }));\n return;\n }\n const cols = (r.columns || []).map((c) => c.name || c);\n const rendered = cols.length ? cols : Object.keys(r.rows[0] || {});\n host.appendChild(Table.render(rendered, r.rows));\n },\n\n openSave() {\n if (!BuilderPane.validate()) return;\n const tableMeta = BuilderPane.findTable(State.builder.table);\n const args = BuilderPane.buildFindManyArgs(State.builder.args, tableMeta);\n State.savePrompt = { kind: 'builder', args, fixedTable: State.builder.table };\n Modal.open('savePromptBackdrop');\n $('#savePromptName').value = '';\n SavePrompt.fillTables(State.builder.table);\n },\n\n bind() {\n $('#builderRun').addEventListener('click', () => BuilderPane.run());\n $('#builderSave').addEventListener('click', () => BuilderPane.openSave());\n $('#builderCopy').addEventListener('click', () => {\n const code = $('#builderPreview').dataset.rawCode || '';\n copyToClipboard(code, 'TypeScript copied');\n });\n $('#builderReset').addEventListener('click', () => {\n if (State.builder.table) BuilderPane.resetForTable(State.builder.table);\n });\n },\n };\n\n // ---------- JS args renderer (used by builder preview) ----------\n function renderJsArgs(obj, depth) {\n if (obj == null) return '';\n const ind = ' '.repeat(depth);\n const indIn = ' '.repeat(depth + 1);\n const keys = Object.keys(obj);\n if (!keys.length) return '';\n const lines = [];\n for (const k of keys) {\n const v = obj[k];\n lines.push(ind + k + ': ' + renderJsValue(v, depth) + ',');\n }\n return lines.join('\\n');\n }\n function renderJsValue(v, depth) {\n if (v === null) return 'null';\n if (v === undefined) return 'undefined';\n if (typeof v === 'boolean') return v ? 'true' : 'false';\n if (typeof v === 'number') return String(v);\n if (typeof v === 'string') return \"'\" + v.replace(/\\\\/g, '\\\\\\\\').replace(/'/g, \"\\\\'\") + \"'\";\n if (Array.isArray(v)) {\n if (!v.length) return '[]';\n return '[' + v.map((x) => renderJsValue(x, depth)).join(', ') + ']';\n }\n if (typeof v === 'object') {\n const keys = Object.keys(v);\n if (!keys.length) return '{}';\n const ind = ' '.repeat(depth + 1);\n const close = ' '.repeat(depth);\n const inner = keys.map((k) => ind + k + ': ' + renderJsValue(v[k], depth + 1)).join(',\\n');\n return '{\\n' + inner + '\\n' + close + '}';\n }\n return String(v);\n }\n\n // ---------- TS syntax highlighter (simple tokenizer) ----------\n function highlightTs(src) {\n const KWS = new Set([\n 'await',\n 'async',\n 'const',\n 'let',\n 'var',\n 'return',\n 'if',\n 'else',\n 'true',\n 'false',\n 'null',\n 'undefined',\n 'import',\n 'from',\n 'export',\n 'new',\n ]);\n const out = [];\n let i = 0;\n while (i < src.length) {\n const ch = src[i];\n // comment\n if (ch === '/' && src[i + 1] === '/') {\n let j = i;\n while (j < src.length && src[j] !== '\\n') j++;\n out.push('<span class=\"tok-comment\">' + escapeHtml(src.slice(i, j)) + '</span>');\n i = j;\n continue;\n }\n // string\n if (ch === \"'\" || ch === '\"' || ch === '`') {\n const q = ch;\n let j = i + 1;\n while (j < src.length && src[j] !== q) {\n if (src[j] === '\\\\') j += 2;\n else j++;\n }\n j = Math.min(src.length, j + 1);\n out.push('<span class=\"tok-str\">' + escapeHtml(src.slice(i, j)) + '</span>');\n i = j;\n continue;\n }\n // number\n if (/[0-9]/.test(ch)) {\n let j = i;\n while (j < src.length && /[0-9_.]/.test(src[j])) j++;\n out.push('<span class=\"tok-num\">' + escapeHtml(src.slice(i, j)) + '</span>');\n i = j;\n continue;\n }\n // identifier / keyword\n if (/[A-Za-z_$]/.test(ch)) {\n let j = i;\n while (j < src.length && /[A-Za-z0-9_$]/.test(src[j])) j++;\n const word = src.slice(i, j);\n // key: identifier followed by colon\n let k = j;\n while (k < src.length && src[k] === ' ') k++;\n if (src[k] === ':') {\n out.push('<span class=\"tok-key\">' + escapeHtml(word) + '</span>');\n } else if (KWS.has(word)) {\n out.push('<span class=\"tok-kw\">' + escapeHtml(word) + '</span>');\n } else if (src[j] === '(' || (src[j] === '.' && false)) {\n out.push('<span class=\"tok-fn\">' + escapeHtml(word) + '</span>');\n } else {\n out.push(escapeHtml(word));\n }\n i = j;\n continue;\n }\n // punctuation\n if (/[{}[\\](),;:]/.test(ch)) {\n out.push('<span class=\"tok-punc\">' + escapeHtml(ch) + '</span>');\n i++;\n continue;\n }\n out.push(escapeHtml(ch));\n i++;\n }\n return out.join('');\n }\n\n // =====================================================================\n // Saved queries\n // =====================================================================\n const SavedQueries = {\n async load(q) {\n if (q.kind === 'builder') {\n if (q.table) {\n const t = State.tablesByName.get(q.table);\n if (t) {\n State.currentTable = t;\n Storage.set('currentTable', t.name);\n }\n }\n Router.selectTab('builder');\n // Re-hydrating from a saved args object is tricky since our internal\n // shape differs from the flat Turbine args object. For now we store\n // the args alongside the builder table and render a hint.\n State.builder.table = q.table;\n State.builder.args = SavedQueries.hydrateBuilderArgs(q.args || {}, q.table);\n BuilderPane.render();\n toast('Loaded saved builder: ' + q.name, 'ok');\n }\n },\n\n hydrateBuilderArgs(argsObj, tableName) {\n // Best-effort inverse of buildFindManyArgs for a flat where obj\n const tableMeta = State.tablesByName.get(tableName);\n const out = emptyBuilderArgs();\n if (!tableMeta) return out;\n if (argsObj.limit) out.limit = argsObj.limit;\n if (argsObj.orderBy) {\n const k = Object.keys(argsObj.orderBy)[0];\n if (k) out.orderBy = { column: k, dir: argsObj.orderBy[k] };\n }\n if (argsObj.where) {\n const w = argsObj.where;\n const collectInto = (obj, combinator) => {\n for (const [col, val] of Object.entries(obj)) {\n if (col === 'AND' || col === 'OR' || col === 'NOT') continue;\n if (val && typeof val === 'object' && !Array.isArray(val)) {\n const op = Object.keys(val).find((k) => k !== 'mode') || 'equals';\n out.where.push({\n column: col,\n op,\n value: val[op],\n insensitive: val.mode === 'insensitive',\n });\n } else {\n out.where.push({ column: col, op: 'equals', value: val, insensitive: false });\n }\n }\n };\n if (w.OR) {\n out.combinator = 'OR';\n for (const c of w.OR) collectInto(c, 'OR');\n } else if (w.AND) {\n for (const c of w.AND) collectInto(c, 'AND');\n } else {\n collectInto(w, 'AND');\n }\n }\n if (argsObj.select) out.select = Object.keys(argsObj.select);\n if (argsObj.omit) out.omit = Object.keys(argsObj.omit);\n if (argsObj.with) {\n for (const [relName, v] of Object.entries(argsObj.with)) {\n const rel = (tableMeta.relations || []).find((r) => r.name === relName);\n if (!rel) continue;\n const childArgs = v === true ? emptyBuilderArgs() : SavedQueries.hydrateBuilderArgs(v, rel.to);\n out.with.push({ relation: relName, expanded: true, args: childArgs });\n }\n }\n return out;\n },\n\n async remove(id) {\n try {\n await Api.deleteSaved(id);\n State.savedQueries = State.savedQueries.filter((q) => q.id !== id);\n Sidebar.renderSaved();\n toast('Deleted saved query', 'ok');\n } catch (err) {\n toast('Delete failed: ' + err.message, 'err');\n }\n },\n\n async refresh() {\n try {\n const res = await Api.savedQueries();\n State.savedQueries = res.queries || [];\n Sidebar.renderSaved();\n } catch {\n // silent on refresh fail\n }\n },\n };\n\n // =====================================================================\n // Modals\n // =====================================================================\n const Modal = {\n open(id) {\n const m = document.getElementById(id);\n if (!m) return;\n m.classList.add('open');\n m.setAttribute('aria-hidden', 'false');\n setTimeout(() => {\n const inp = m.querySelector('input,textarea,select,button');\n if (inp) inp.focus();\n }, 20);\n },\n close(id) {\n const m = document.getElementById(id);\n if (!m) return;\n m.classList.remove('open');\n m.setAttribute('aria-hidden', 'true');\n },\n bind() {\n $$('[data-close-modal]').forEach((b) => {\n b.addEventListener('click', () => Modal.close(b.dataset.closeModal));\n });\n $$('.modal-backdrop').forEach((bd) => {\n bd.addEventListener('click', (e) => {\n if (e.target === bd) Modal.close(bd.id);\n });\n });\n },\n };\n\n const JsonModal = {\n open(val) {\n const host = $('#jsonModalBody');\n host.textContent = JSON.stringify(val, null, 2);\n Modal.open('jsonModalBackdrop');\n },\n };\n\n const SavePrompt = {\n fillTables(preferred) {\n const sel = $('#savePromptTable');\n sel.innerHTML = '';\n for (const t of State.tables) {\n const opt = el('option', { value: t.name, text: t.name });\n sel.appendChild(opt);\n }\n const target =\n preferred ||\n (State.currentTable && State.currentTable.name) ||\n (State.tables[0] && State.tables[0].name);\n if (target) sel.value = target;\n },\n bind() {\n $('#savePromptConfirm').addEventListener('click', async () => {\n const name = $('#savePromptName').value.trim();\n const table = $('#savePromptTable').value;\n if (!name || !table) {\n toast('Name and table required', 'err');\n return;\n }\n const payload = {\n table,\n name,\n kind: 'builder',\n args: State.savePrompt.args,\n };\n try {\n await Api.saveQuery(payload);\n Modal.close('savePromptBackdrop');\n toast('Query saved', 'ok');\n SavedQueries.refresh();\n } catch (err) {\n toast('Save failed: ' + err.message, 'err');\n }\n });\n },\n };\n\n // =====================================================================\n // Command palette\n // =====================================================================\n const Palette = {\n bind() {\n $('#openPalette').addEventListener('click', () => Palette.open());\n const input = $('#paletteInput');\n input.addEventListener('input', () => Palette.render());\n input.addEventListener('keydown', (e) => {\n if (e.key === 'Escape') {\n Modal.close('paletteBackdrop');\n return;\n }\n if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {\n e.preventDefault();\n const items = $$('.palette-item');\n if (!items.length) return;\n const cur = items.findIndex((n) => n.classList.contains('selected'));\n const next =\n e.key === 'ArrowDown'\n ? Math.min(items.length - 1, cur + 1)\n : Math.max(0, cur - 1);\n items.forEach((n, idx) => n.classList.toggle('selected', idx === next));\n items[next].scrollIntoView({ block: 'nearest' });\n }\n if (e.key === 'Enter') {\n const sel = $('.palette-item.selected');\n if (sel) sel.click();\n }\n });\n },\n\n open() {\n Modal.open('paletteBackdrop');\n $('#paletteInput').value = '';\n Palette.render();\n },\n\n render() {\n const q = $('#paletteInput').value.toLowerCase().trim();\n const list = $('#paletteList');\n list.innerHTML = '';\n\n const items = [];\n\n // Commands\n const cmds = [\n { name: 'Go to Query tab', kbd: ['G', 'Q'], run: () => Router.selectTab('builder') },\n { name: 'Go to Data tab', kbd: ['G', 'D'], run: () => Router.selectTab('data') },\n { name: 'Go to Schema tab', kbd: ['G', 'S'], run: () => Router.selectTab('schema') },\n { name: 'Run query', kbd: [isMac() ? '⌘' : 'Ctrl', '↵'], run: () => { Router.selectTab('builder'); setTimeout(() => BuilderPane.run(), 50); } },\n { name: 'Save query', kbd: [isMac() ? '⌘' : 'Ctrl', 'S'], run: () => { Router.selectTab('builder'); BuilderPane.openSave(); } },\n { name: 'Refresh data', kbd: ['R'], run: () => DataPane.loadAndRender() },\n { name: 'Reload schema', kbd: ['Shift', 'R'], run: () => boot(true) },\n ];\n const filteredCmds = q\n ? cmds.filter((c) => c.name.toLowerCase().includes(q))\n : cmds;\n if (filteredCmds.length) {\n list.appendChild(el('div', { class: 'palette-section', text: 'Commands' }));\n for (const c of filteredCmds) items.push(Palette.renderItem(c.name, c.kbd, c.run));\n }\n\n // Tables\n const tables = State.tables.filter(\n (t) => !q || t.name.toLowerCase().includes(q)\n );\n if (tables.length) {\n list.appendChild(el('div', { class: 'palette-section', text: 'Tables' }));\n for (const t of tables.slice(0, 50)) {\n items.push(\n Palette.renderItem(t.name, [formatCount(t.estimatedRows)], () => {\n Router.selectTable(t.name);\n Modal.close('paletteBackdrop');\n })\n );\n }\n }\n\n if (!items.length) {\n list.appendChild(el('div', { class: 'empty', style: { padding: '24px' }, text: 'No matches.' }));\n }\n for (const it of items) list.appendChild(it);\n const first = list.querySelector('.palette-item');\n if (first) first.classList.add('selected');\n },\n\n renderItem(name, kbd, run) {\n const kbdEls = (kbd || []).map((k) => el('span', { class: 'kbd', text: k }));\n const item = el('div', { class: 'palette-item' }, [\n el('div', { class: 'pi-name' }, [\n el(\n 'svg',\n {\n class: 'pi-icon',\n viewBox: '0 0 24 24',\n fill: 'none',\n stroke: 'currentColor',\n 'stroke-width': '2',\n },\n []\n ),\n el('span', { text: name }),\n ]),\n el('div', { class: 'pi-kbd' }, kbdEls),\n ]);\n item.addEventListener('click', () => {\n run();\n Modal.close('paletteBackdrop');\n });\n return item;\n },\n };\n\n // =====================================================================\n // Keyboard shortcuts\n // =====================================================================\n function bindKeys() {\n document.addEventListener('keydown', (e) => {\n const cmd = e.metaKey || e.ctrlKey;\n // Cmd+K opens palette\n if (cmd && (e.key === 'k' || e.key === 'K')) {\n e.preventDefault();\n Palette.open();\n return;\n }\n if (e.key === 'Escape') {\n ['savePromptBackdrop', 'jsonModalBackdrop', 'paletteBackdrop'].forEach((id) =>\n Modal.close(id)\n );\n return;\n }\n if (isTypingInInput()) return;\n if (e.key === '/') {\n e.preventDefault();\n $('#sidebarSearch').focus();\n }\n if (e.key === '?') {\n Palette.open();\n }\n });\n }\n\n // =====================================================================\n // Init / boot\n // =====================================================================\n async function boot(reloadOnly) {\n try {\n const schema = await Api.schema();\n State.schema = schema;\n State.tables = schema.tables || [];\n State.tablesByName = new Map(State.tables.map((t) => [t.name, t]));\n State.enums = schema.enums || {};\n State.connOk = true;\n $('#connDot').classList.add('ok');\n $('#connLabel').textContent = 'connected';\n const meta =\n (schema.schema || 'public') + ' · ' + State.tables.length + ' tables';\n $('#headerMeta').textContent = meta;\n $('#sidebarMeta').textContent = meta;\n\n // Restore\n const savedW = Storage.get('sidebarW');\n if (savedW) document.documentElement.style.setProperty('--sidebar-w', savedW);\n const savedTab = Storage.get('tab', 'builder');\n const savedTable = Storage.get('currentTable');\n const initialTable = (savedTable && State.tablesByName.has(savedTable)\n ? savedTable\n : State.tables[0] && State.tables[0].name);\n if (initialTable) {\n State.currentTable = State.tablesByName.get(initialTable);\n }\n\n Sidebar.render();\n Router.selectTab(savedTab || 'builder');\n if (State.currentTable) {\n if (savedTab === 'data') DataPane.loadAndRender();\n if (savedTab === 'schema') SchemaPane.render();\n if (savedTab === 'builder' || !savedTab) BuilderPane.resetForTable(State.currentTable.name);\n }\n\n SavedQueries.refresh();\n } catch (err) {\n State.connOk = false;\n $('#connDot').classList.remove('ok');\n $('#connDot').classList.add('err');\n $('#connLabel').textContent = 'offline';\n $('#headerMeta').textContent = 'connection failed';\n $('#sidebarMeta').textContent = '—';\n const mainErr = el('div', { class: 'error-box' }, [\n el('div', { class: 'error-box-title', text: 'Cannot reach /api/schema' }),\n document.createTextNode(err.message),\n ]);\n $('#dataScroll').innerHTML = '';\n $('#dataScroll').appendChild(mainErr);\n }\n }\n\n function init() {\n Sidebar.bind();\n Router.bind();\n DataPane.bind();\n BuilderPane.bind();\n Modal.bind();\n SavePrompt.bind();\n Palette.bind();\n bindKeys();\n boot();\n }\n\n document.addEventListener('DOMContentLoaded', init);\n </script>\n</body>\n</html>\n";
|
|
4
4
|
//# sourceMappingURL=studio-ui.generated.js.map
|