diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..be35f39 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +# Default reviewer for everything. Update with a team (e.g. @Altinity/) +# if/when one is set up. +* @BorisTyshkevich diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..3efc03b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,17 @@ +--- +name: Bug report +about: Something isn't working +labels: bug +--- + +### What happened + + +### Environment +- Browser + version: +- ClickHouse server (version / Antalya or OSS): +- Auth mode (OAuth IdP / credentials): +- App version (from the build, if shown): + +### Notes + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..01be308 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,14 @@ +--- +name: Feature request +about: Suggest an enhancement +labels: enhancement +--- + +### Problem / motivation + + +### Proposed solution + + +### Notes + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..a50dbaf --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,10 @@ +## What & why + + +## Checklist +- [ ] `npm test` passes (the per-file coverage gate is non-negotiable) +- [ ] Tests added/updated in the same change as the code +- [ ] `npm run build` succeeds (single-file `dist/sql.html`) +- [ ] Layers kept honest: pure logic in `src/core/`, network in `src/net/` (injected fetch), DOM in `src/ui/` +- [ ] No new runtime dependency (or it's a deliberate, justified addition — see CONTRIBUTING) +- [ ] README / `CHANGELOG.md` (`[Unreleased]`) updated if behavior or the deployed surface changed diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f330087 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +# Dependency update alerts/PRs. The two runtime deps (Chart.js, @dagrejs/dagre) +# are inlined into the shipped dist/sql.html, so a vuln in them ships to every +# browser that loads the page — keep them watched, alongside devDeps and Actions. +version: 2 +updates: + - package-ecosystem: npm + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + dev-dependencies: + dependency-type: development + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..721a80f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,64 @@ +# Changelog + +All notable changes to this project are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project aims to +adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +GitHub Releases (cut from `v*` tags by `.github/workflows/release.yml`) carry the +auto-generated per-PR notes; this file is the curated, human-readable history. + +## [Unreleased] + +### Added +- `NOTICE` + `THIRD-PARTY-NOTICES.md`, and the bundled Chart.js / dagre (MIT) + notices are now embedded in the built `dist/sql.html`. +- `CONTRIBUTING.md` and this `CHANGELOG.md`. +- Dependabot configuration for npm + GitHub Actions updates. + +## [0.1.4] - 2026-06-28 + +### Changed +- Schema detail pane: removed the "Insert SHOW CREATE" action button; opening a + node now rings its card (a double border) and the ring clears on every + pane-close path including Esc (#65). +- Code-review follow-ups for the schema/zoom work: extracted `schemaLayout()` and + a `fixedAnchor()` helper, and the transitive-lineage node cap now counts only + linked nodes so a large single database isn't truncated early (#64). + +## [0.1.3] - 2026-06-28 + +### Changed +- Whole-database schema graph now draws **every** table (linked or not), packs the + unlinked tables into a grid below the lineage, and drops the redundant `.` + prefix from node labels for objects in the focused database (#63). + +## [0.1.2] - 2026-06-28 + +### Fixed +- Bridged the shipped `html { zoom }` across the full-view schema panel and the + splitter / detail-pane-resize / popover coordinate math, so the full view fits + one screen (the detail-pane DDL was previously pushed off-screen) and drags and + popovers track the cursor (#62). + +## [0.1.1] - 2026-06-28 + +### Added +- `antalya-oauth` demo connection (Google SSO). + +### Changed +- Documentation updates; dropped the inaccurate "zero-dependency" framing (the + app bundles two deliberate runtime dependencies). + +## [0.1.0] - 2026-06-28 + +### Added +- Initial release: OAuth-gated (PKCE) single-file SQL browser served from + ClickHouse — SQL editor, sortable results table + chart view, EXPLAIN pipeline + graph, and the schema data-flow graph. Built by esbuild into one `dist/sql.html`. + +[Unreleased]: https://github.com/Altinity/altinity-sql-browser/compare/v0.1.4...HEAD +[0.1.4]: https://github.com/Altinity/altinity-sql-browser/compare/v0.1.3...v0.1.4 +[0.1.3]: https://github.com/Altinity/altinity-sql-browser/compare/v0.1.2...v0.1.3 +[0.1.2]: https://github.com/Altinity/altinity-sql-browser/compare/v0.1.1...v0.1.2 +[0.1.1]: https://github.com/Altinity/altinity-sql-browser/compare/v0.1.0...v0.1.1 +[0.1.0]: https://github.com/Altinity/altinity-sql-browser/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7f9cf28 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,64 @@ +# Contributing to the Altinity SQL Browser + +Thanks for your interest! This is a modular, no-framework ES-module SPA that +builds to **one self-contained HTML file** (`dist/sql.html`) served from a +ClickHouse cluster. Quality is held by tests and a strict layering discipline — +please read the hard rules below before opening a PR. + +## Quickstart + +```bash +npm install +npm test # vitest + coverage gate (must pass) +npm run build # esbuild → dist/sql.html +npm run local # build, then serve locally with a connection picker +npm run test:e2e # Playwright (chromium + firefox); needs: npx playwright install chromium firefox +``` + +Requirements: Node 22, a POSIX shell. No other toolchain. + +## Hard rules (non-negotiable) + +These mirror `CLAUDE.md` (the in-repo agent guide) — the same rules apply to human +contributors. + +1. **The coverage gate must pass.** `npm test` enforces **100% per-file** for the + pure / network / state / DOM / render layers. `src/ui/app.js` + `src/main.js` + are the browser glue, gated lower and integration-tested. **Add tests in the + same change as the code.** +2. **Keep the layers honest.** + - Pure logic → `src/core/` (no DOM, no globals). + - Network → `src/net/` with the `fetch` seam **injected**, never imported. + - DOM rendering → `src/ui/` as functions that take the `app` controller. + - Side-effectful environment access (location, crypto, storage, fetch) is + injected through `createApp(env)` so everything is testable under happy-dom. +3. **No secrets in git.** `config.json` (rendered) is gitignored; only + `deploy/config.json.example` is committed. `config.json` is served to browsers + — prefer a PKCE public client (see the README "Configuring OAuth" and + `SECURITY.md`). +4. **The build is esbuild only; runtime deps are rare and deliberate.** There are + exactly **two** bundled runtime dependencies — **Chart.js** and + **@dagrejs/dagre** — both inlined so the page makes zero third-party requests. + Adding another is a deliberate decision that grows the single served file. When + a feature needs a library, keep the testable logic pure in `src/core/` and make + the library call an **injected seam** (like `app.Chart` / `app.Dagre`). + +## How to add a result view / panel / feature + +Touch these in one change: +- the module under `src/core/` (pure logic) or `src/ui/` (render); +- its `tests/unit/.test.js` to 100%; +- if it changes the deployed surface, `deploy/http_handlers.xml` + the README. + +## Pull requests + +- Branch off `main`; keep PRs focused. +- `npm test` green (coverage gate) and `npm run build` succeeds. +- Update the README / `CHANGELOG.md` (`[Unreleased]`) when behavior or the + deployed surface changes. +- Releases are cut by pushing a `vX.Y.Z` tag (see `.github/workflows/release.yml`). + +## Reporting bugs / security + +Open a GitHub issue for bugs and feature requests. For security-sensitive +reports, follow `SECURITY.md` instead of filing a public issue. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..98016f3 --- /dev/null +++ b/NOTICE @@ -0,0 +1,11 @@ +Altinity SQL Browser +Copyright 2026 Altinity, Inc. + +This product is licensed under the Apache License, Version 2.0 (see LICENSE). + +It bundles the following third-party components into the built single-file +artifact (dist/sql.html). Their licenses and copyright notices are reproduced in +THIRD-PARTY-NOTICES.md and are embedded in the artifact: + + - Chart.js (MIT) — https://www.chartjs.org + - @dagrejs/dagre (MIT) — https://github.com/dagrejs/dagre diff --git a/README.md b/README.md index e1a9ae9..3f39d13 100644 --- a/README.md +++ b/README.md @@ -76,10 +76,9 @@ in-memory schema. Highlighting then tracks the connected server's actual keyword/function set, so it's version-correct. Folding and multi-cursor are out of scope for a textarea and tracked separately (CodeMirror, issue #21). -> Design source of truth: the handoff bundle under `design/` (imported from the -> "Altinity Play" Claude Design project) — read `design/README.md` before UI -> work. The `.jsx` files there are React prototypes; production is the vanilla -> ES-module code under `src/`. +> Design source of truth: the "Altinity Play" Claude Design project (external). +> Production is the vanilla ES-module code under `src/` — there is no React in +> the shipped app. ## EXPLAIN views @@ -445,7 +444,6 @@ src/ styles.css build/ esbuild → single-file dist/sql.html deploy/ install.sh, uninstall.sh, http_handlers.xml, config.json.example -design/ imported design handoff bundle (UI spec; reference only, not built) tests/ vitest + happy-dom, one spec per module docs/ ARCHITECTURE.md, DEPLOYMENT.md, ASSET-DISTRIBUTION.md, CLICKHOUSE-OAUTH.md, CLICKHOUSE-OSS-OAUTH.md diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md new file mode 100644 index 0000000..37cdacf --- /dev/null +++ b/THIRD-PARTY-NOTICES.md @@ -0,0 +1,34 @@ +# Third-party notices + +The Altinity SQL Browser is licensed under Apache-2.0 (see `LICENSE`). The built +single-file artifact (`dist/sql.html`) inlines the two runtime dependencies +below; this file reproduces their MIT license texts as required, and the same +notices are embedded as a comment at the top of the built artifact. + +--- + +## Chart.js — v4.5.1 + +The MIT License (MIT) + +Copyright (c) 2014-2024 Chart.js Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- + +## @dagrejs/dagre — v3.0.0 + +The MIT License (MIT) + +Copyright (c) 2012-2014 Chris Pettitt + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/build/build.mjs b/build/build.mjs index a0acc37..fb3916f 100644 --- a/build/build.mjs +++ b/build/build.mjs @@ -28,7 +28,15 @@ async function main() { const styles = await readFile(resolve(root, 'src/styles.css'), 'utf8'); const template = await readFile(resolve(here, 'template.html'), 'utf8'); + // The two runtime deps (Chart.js, dagre) are MIT and inlined into the bundle, + // so the artifact must carry their notices. esbuild strips legal comments + // (legalComments: 'none'), so embed THIRD-PARTY-NOTICES.md as a leading HTML + // comment — sanitized so its text can't close the comment early. + const notices = (await readFile(resolve(root, 'THIRD-PARTY-NOTICES.md'), 'utf8')).replace(/--+>?/g, '-'); + const thirdParty = ''; + const html = template + .replace('', () => thirdParty) .replace('/*__STYLES__*/', () => styles) .replace('/*__SCRIPT__*/', () => script); diff --git a/build/template.html b/build/template.html index 324255d..46adfff 100644 --- a/build/template.html +++ b/build/template.html @@ -1,6 +1,7 @@ + Altinity SQL Browser @@ -8,6 +9,7 @@ +
diff --git a/design/Altinity Play.html b/design/Altinity Play.html deleted file mode 100644 index f069832..0000000 --- a/design/Altinity Play.html +++ /dev/null @@ -1,212 +0,0 @@ - - - - - -Altinity Play — Redesigned - - - - - - -
- - - - - - - - - - - - - - - - - - - diff --git a/design/IMPORTED.md b/design/IMPORTED.md deleted file mode 100644 index d779481..0000000 --- a/design/IMPORTED.md +++ /dev/null @@ -1,14 +0,0 @@ -# Design reference bundle (imported) - -This directory is a verbatim snapshot of the **"sql browser"** Claude Design -project's `design_handoff_altinity_play/` handoff bundle — the UI source-of-truth -for the editor-enhancement work (issues #23–#27). - -**Reference only — not shipped.** These are React/Babel prototypes. The production -app is the framework-free vanilla-ES-module SPA under `src/`. esbuild bundles only -`src/main.js` → `dist/sql.html`, so nothing here is built into the served artifact, -and `tests/` coverage (`include: ['src/**/*.js']`) never sees it. - -Start with `README.md` (the full handoff: design tokens, region-by-region spec, and -the per-issue editor-enhancement reference). The `.jsx` files are the reference -implementations to port. diff --git a/design/ISSUE-publish-as-markdown.md b/design/ISSUE-publish-as-markdown.md deleted file mode 100644 index e892fd7..0000000 --- a/design/ISSUE-publish-as-markdown.md +++ /dev/null @@ -1,87 +0,0 @@ -# Feature: "Publish" — export all saved queries as a Markdown cookbook - -## Summary - -Add a one-way **Markdown export** ("Publish" / "Copy as Markdown") that turns the -user's saved queries into a single human-readable Markdown document they can paste -into other tools (GitHub, GitLab, Notion, Obsidian, wikis, PRs, Slack) or download -as a `.md` file. - -This complements — does **not** replace — the existing **JSON export/import**: - -| | JSON export | Markdown publish | -|---|---|---| -| Purpose | Backup / transfer / re-import | Share / document / paste elsewhere | -| Round-trips back in? | ✅ lossless | ❌ one-way (metadata not recoverable) | -| Human-readable | meh | ✅ great | - -**Markdown is strictly export-only.** Do not attempt to re-import it — `starred`, -timestamps, and ids do not survive. JSON remains the canonical round-trip format. - -## Output format - -Each saved query becomes a heading + a fenced `sql` block (the fence gives free -syntax highlighting wherever it's pasted). Group **starred first**, then the rest, -and include a linked table of contents once there are more than ~10 queries -(headings auto-anchor on GitHub). - -```markdown -# Saved queries -_42 queries · exported from Altinity SQL Browser · 2026-06-21_ - -## ⭐ Starred - -### Worst-delay carriers (2023) -​```sql -SELECT Reporting_Airline, avg(DepDelayMinutes) AS avg_delay -FROM airline.ontime -WHERE Year = 2023 AND Cancelled = 0 -GROUP BY Reporting_Airline -ORDER BY avg_delay DESC -LIMIT 15 -​``` - -## All queries - -### Busiest origin airports -​```sql -SELECT Origin, count() AS flights FROM airline.ontime ... -​``` -``` - -## UX - -- Primary action: **Copy to clipboard** (the stated use case is "cut it and use - elsewhere"). -- Secondary: **Download `.md`**. -- Show a **preview modal** with the generated Markdown in a scrollable `
` so
-  the user can eyeball it before copying — Markdown is reviewed-before-paste,
-  unlike the fire-and-forget JSON backup.
-- Sits alongside the existing Export / Import controls at the bottom of the Saved
-  panel.
-
-## Open decisions
-
-1. **Scope** — publish *all* saved queries, or let the user pick (starred-only, or
-   multi-select)?
-2. **Naming** — "Copy as Markdown" (honest about what it does) vs keep "Publish"
-   and eventually make it *actually* publish (create a GitHub Gist or a shareable
-   read-only URL, with copy/download as offline fallbacks).
-3. **`description` field (recommended)** — saved queries are currently `name` +
-   `sql` only. A published cookbook is far more useful if each query carries a
-   one-line description, rendered as prose under its heading. Consider adding an
-   optional `description` field to the saved-query schema as part of this work.
-
-## Implementation notes
-
-- **Fence safety**: SQL almost never contains a literal triple-backtick, but scan
-  each query and bump to a 4-backtick fence if one is present.
-- Clipboard via `navigator.clipboard.writeText`; download via
-  `Blob` + `URL.createObjectURL` (same pattern as the JSON export).
-- Suggested filename: `sql-browser-queries-YYYY-MM-DD.md`.
-
-## Context
-
-Discussed during the design handoff. Deferred from the current design round for
-more thought before committing. See the handoff README's "Export / Import saved
-queries" section for the JSON counterpart this builds on.
diff --git a/design/Login.html b/design/Login.html
deleted file mode 100644
index 2433d46..0000000
--- a/design/Login.html
+++ /dev/null
@@ -1,304 +0,0 @@
-
-
-
-
-
-Altinity SQL Browser — Sign in
-
-
-
-
-
-
-
- - - - - - - - - - - - diff --git a/design/README.md b/design/README.md deleted file mode 100644 index 6747051..0000000 --- a/design/README.md +++ /dev/null @@ -1,1006 +0,0 @@ -# Handoff: Altinity Play — Redesigned Query Workbench - -## Overview - -This is a redesign of the Altinity Antalya `/play-a` page (the ClickHouse-flavored -SQL playground at `https://antalya.demo.altinity.cloud/play-a`). The original -page is essentially a single textarea + Run button + results table. This redesign -turns it into a modern data workbench in the spirit of DataGrip / Postgres.app / -Linear — schema-first, multi-tab, with polished results, charts, and history. - -The primary user is a **ClickHouse newcomer exploring the public demo data**; -the experience should feel approachable but still pro-grade. - ---- - -## About the Design Files - -The files in this bundle are **design references**. They are working HTML -prototypes built with React + Babel inline transpilation, intended to demonstrate -the intended **look, layout, behavior, and interactions** — not to be shipped as -production code. - -**Your task is to recreate these designs in the target codebase's existing -environment** (the live `/play-a` app, presumably a React/Vue/Svelte SPA served -by ClickHouse + Altinity infra), using its established patterns, component -library, routing, styling solution, and data layer. If the project has no -existing frontend stack yet, choose the most appropriate framework — React + -Vite + TypeScript is a reasonable default — and implement there. - -When you start, please: - -1. Open `Altinity Play.html` in a browser to see the design live. -2. Read this README in full. -3. Skim the `.jsx` files for the exact structure, props, and interaction logic - you'll need to mirror. -4. Identify the equivalent components/primitives in the target codebase before - re-implementing from scratch. - ---- - -## Fidelity - -**High-fidelity.** All colors, typography, spacing, border radii, and -interactions are intentional and final. Recreate pixel-perfectly using the -codebase's existing libraries and patterns. Do not substitute "close enough" -values for the design tokens listed below. - -The one exception: the syntax-highlighted SQL editor in the prototype is -hand-rolled (transparent textarea over a styled `
` for highlighting). In
-production, **swap this for Monaco Editor or CodeMirror 6** — it's expected
-behavior, not a stylistic choice. Match the visual treatment (line gutter
-style, font, line height, color palette) to what the prototype shows.
-
----
-
-## Screen: Sign in (`Login.html`)
-
-The connection/login screen shown before the workbench. Three auth paths,
-encoded directly in the UI. Centered 400px card on the app's dark bg
-(`radial` accent glow behind it), same tokens/fonts as the workbench.
-
-**The rules (this is the important part):**
-1. **SSO is the default.** A primary "Continue with SSO" button authenticates
-   on **the current host** (the server serving the page —
-   `CURRENT_HOST`, e.g. `otel.demo.altinity.cloud`). OAuth is configured
-   per-deployment, so SSO is always bound to the current host — it does **not**
-   honor the host override. Helper text states this.
-2. **Credentials override SSO.** When username **and** password are both
-   non-empty, the UI flips: **Connect** becomes the primary (accent) button and
-   SSO demotes to a secondary (`btn-ghost`) outline — visually encoding "these
-   are used instead of SSO." Enter submits; password has a show/hide toggle.
-3. **Optional host:port override.** Under an **Advanced** disclosure (collapsed
-   by default so the common SSO path stays clean): a single "Server address
-   (host:port)" field. Blank → use the current host. A value → connect there
-   **for the credential path only** (per rule 1, SSO ignores it).
-
-**Live target summary**: a mono status row pinned near the bottom always
-resolves the combined state — `Target: ` on the left, and
-`as ` (credential path) or `via SSO` on the right — so the
-interaction of the three rules is never ambiguous. `effectiveHost =
-hostOverride.trim() || CURRENT_HOST`.
-
-**State / logic** (all local in the prototype):
-- `hasCreds = username.trim() && password` → drives the primary/secondary swap
-  and enables the Connect button.
-- `effectiveHost` as above.
-- `busy` ∈ {`'sso'`,`'creds'`,null} → button label becomes "Redirecting…" /
-  "Connecting…". The prototype just times out after 1.6s; **wire to the real
-  OAuth redirect / ClickHouse auth in production.**
-
-**Production wiring:**
-- **SSO** → kick off the existing OAuth flow against the current origin
-  (the same one used today at `/sql`).
-- **Credentials** → authenticate against ClickHouse at `effectiveHost`
-  (HTTP interface; `Authorization: Basic` or `X-ClickHouse-User` /
-  `X-ClickHouse-Key`). Validate host:port input; default the port if omitted
-  (8443 https / 9440 native-secure as appropriate).
-- Treat host override as untrusted input; constrain scheme/port as your
-  security model requires.
-- On success, hand off to the workbench (`Altinity Play.html` equivalent) with
-  the resolved connection in context.
-
-**Footer**: GitHub "Source" link + version chip, matching the workbench header.
-**Tweaks**: theme + accent (same as the workbench), so the login matches
-whatever palette the app ships with.
-
----
-
-## Layout / Screens
-
-There is one main screen — the workbench — composed of four regions:
-
-```
-┌──────────────────────────────────────────────────────────────────────┐
-│ HEADER (44px)                                                        │
-├────────────────┬─────────────────────────────────────────────────────┤
-│                │ TABS (34px)                                         │
-│   SIDEBAR      ├─────────────────────────────────────────────────────┤
-│   (resizable,  │ EDITOR TOOLBAR (38px)                               │
-│   180–420px,   ├─────────────────────────────────────────────────────┤
-│   default 248) │                                                     │
-│                │ SQL EDITOR  (top half, default 45%)                 │
-│   • Schema     │                                                     │
-│   • Saved/Hist ├──────── 4px draggable splitter ─────────────────────┤
-│   (vertical    │ RESULTS TOOLBAR (36px)                              │
-│   split, 60/40)├─────────────────────────────────────────────────────┤
-│                │ RESULTS PANE                                        │
-│                │ (table / chart / json view toggle)                  │
-└────────────────┴─────────────────────────────────────────────────────┘
-                              ⇣
-                  Floating Tweaks panel (dev tool;
-                  not shipped to end users)
-```
-
-Editor-first split: the editor gets 45% of vertical space by default; the
-horizontal splitter between editor and results is draggable (15–85%).
-
----
-
-## Region 1: Header (44px tall)
-
-Background: `--bg-header`. Bottom border: 1px `--border`.
-Padding: `0 14px`. Flex row, 14px gap.
-
-**Left cluster:**
-- **Logo tile**: 22×22, `border-radius: 5`, gradient `linear-gradient(135deg,
-  var(--accent), color-mix(in oklab, var(--accent) 70%, #000))`. White "A" inside,
-  font-weight 700, 12px.
-- **Wordmark**: "Altinity Play", 13px / 600 / `--fg`.
-- **Connection chip**: `antalya.demo` in mono font, 11px / `--fg-faint`,
-  `--bg-chip` background, padding `2px 6px`, radius 4. `white-space: nowrap`.
-
-**Spacer (`flex: 1`)**
-
-**Right cluster:** (`flex-shrink: 0`, `white-space: nowrap`)
-- **Live status**: 7×7 green dot (`#22c55e`) with `box-shadow: 0 0 6px #22c55e`,
-  followed by mono text "ClickHouse 26.3.10" at 11.5px / `--fg-mute`.
-- **GitHub link** (``, github glyph): 26×26, transparent, hover `--bg-hover`.
-  `target="_blank" rel="noopener noreferrer"`, `aria-label`/title "View on GitHub".
-- **Shortcuts button** (`?` icon): 26×26, transparent, hover `--bg-hover`.
-- **User menu**: avatar chip (24×24, radius 12, `--bg-chip`, initials "DM") +
-  chevron, wrapped in a button. Click opens a dropdown (width 230) with:
-  identity header (accent-filled 32px avatar, name, email), a role line
-  ("Read-only · demo", mono, `--fg-faint`), and a red **Log out** item
-  (`#ef4444`, `Icon.logout`). Clicking Log out opens a **confirmation dialog**
-  (340px, centered, blurred backdrop) explaining that unsaved tabs stay in the
-  browser and saved queries are kept, with Cancel / Log out (red) buttons.
-  An invisible full-viewport overlay behind the dropdown closes it on outside
-  click.
-
----
-
-## Region 2: Sidebar (resizable, default 248px wide, min 180, max 420)
-
-Background: `--bg-side`. Right border 1px `--border`. Vertical split into:
-
-### 2a. Schema browser (top, ~60% height)
-
-- **Search field**: 26px tall input with magnifier icon at left (12px, 8px from
-  left edge). Placeholder "Search tables, columns…", 11.5px. `--bg-input`
-  background, 1px `--border`, radius 5. Filters tree live.
-- **Tree** (4px vertical padding, scrollable):
-  - **Database row** (24px tall, 10px left padding + 14px per indent level):
-    - Chevron (right when collapsed, down when expanded) at left.
-    - Database icon, `--fg-mute`.
-    - Name, 12px / 600 / `--fg`.
-    - Child count, 10px mono / `--fg-faint`, right-aligned.
-  - **Table row** (24px tall, indent 1):
-    - Chevron if has columns.
-    - Table icon in `--accent` color.
-    - Name, 12px / 400 / `--fg-mute`.
-    - Row count (e.g. "198.3M"), 10px mono / `--fg-faint`.
-  - **Column row** (22px tall, indent 2):
-    - Column icon, `--fg-faint`.
-    - Name, 11px mono / `--fg-mute`. Click to insert into editor.
-    - Type badge (e.g. "UInt16"), 10px mono / `--fg-faint`.
-- Hover: `--bg-hover` background.
-- Search-match highlight: `--bg-highlight` (translucent accent).
-
-### 2b. Vertical resize handle (6px, `--border`, `cursor: row-resize`)
-
-### 2c. Saved / History panel (bottom, ~40% height)
-
-- **Tabs row** (30px): "★ Saved" and "⏱ History", each `flex: 1`, no border,
-  underline 2px in `--accent` on active. 11.5px / 500.
-- **Saved item**:
-  - Padding `8px 10px`, 1px `--border-faint` bottom.
-  - Star icon (filled if `starred`, in `--accent`); fallback `--fg-faint`.
-  - Name, 12px / 500 / `--fg`, single line + ellipsis.
-  - Below: SQL preview (first line), 10.5px mono / `--fg-faint`, 18px left
-    indent, single line + ellipsis.
-  - Click → opens as new tab.
-- **History item**:
-  - Padding `8px 10px`, 1px `--border-faint` bottom.
-  - SQL preview, 11px mono / `--fg`, single line + ellipsis.
-  - Below: meta row, 10px mono / `--fg-faint`, 10px gap: relative time, row
-    count, ms.
-  - Click → re-run as new tab.
-
----
-
-## Region 3: Tabs row (34px)
-
-Background: `--bg-tabs`. Bottom 1px `--border`.
-
-- Each tab: 100px min-width, padding `0 8px 0 12px`, right border 1px.
-- Active tab: background `--bg-editor`, name 11.5px / 500 / `--fg`, **2px top
-  bar in `--accent`** (absolutely positioned).
-- Inactive tab: `--fg-mute`, 400 weight.
-- Tab name + (if dirty) 5px gray dot + (if multi) 16×16 close × button.
-- **+ button** at far right: 32px wide, 1px left border, plus icon centered,
-  `--fg-mute` → `--fg` on hover. ⌘T also creates new tab.
-
----
-
-## Region 4: Editor toolbar (38px)
-
-Background: `--bg-toolbar`. Bottom 1px `--border`. `0 10px` padding, 8px gap.
-
-- **Run button**:
-  - 26px tall, padding `0 10px 0 8px`.
-  - Background: `--accent`, color: white.
-  - 11.5px / 600. Radius 5.
-  - Icon (play triangle) + "Run" + small `⌘↵` kbd inside (rgba(0,0,0,.2) bg,
-    9.5px mono).
-  - Disabled (running) state: opacity 0.7, label "Running…", cursor wait.
-- **Format button**: tb-btn class — transparent, `--fg-mute` → `--fg` on hover,
-  `--bg-hover`. Has `{ }` mono glyph + "Format". `white-space: nowrap`.
-- **Spacer**
-- **Share button**: tb-btn, share-graph icon + "Share".
-- **Format select**: dropdown for output format (TSV/CSV/JSON/Pretty), 1px
-  `--border` outline, custom chevron SVG.
-
----
-
-## Region 5: SQL Editor
-
-- Mono font: `'JetBrains Mono', 'SF Mono', ui-monospace, monospace`. 13px (12.5px
-  in compact density). Line-height 1.7 (1.5 compact). Padding `12px 14px` (8px
-  vertical in compact).
-- Background: `--bg-editor`. Caret color: `--accent`.
-- **Line gutter**: 44px wide, right-aligned, padding `padY 8px padY 0`. Text
-  `--fg-faint`, mono, tabular-nums. Right border 1px `--border`. Background
-  `--bg-gutter`. Scrolls in lockstep with the editor.
-- **Tab key** inserts 2 spaces. (When swapping in Monaco/CodeMirror, this is
-  handled natively.)
-
-### SQL syntax highlight palette
-
-| Token   | Dark         | Light      |
-|---------|--------------|------------|
-| keyword | `#C586C0` 500| `#AF00DB`  |
-| func    | `#DCDCAA`    | `#795E26`  |
-| string  | `#CE9178`    | `#A31515`  |
-| number  | `#B5CEA8`    | `#098658`  |
-| comment | `#6A9955` italic | `#008000` italic |
-| ident   | `--fg`       | `--fg`     |
-| op      | `--fg-mute`  | `--fg-mute`|
-
-Keyword and function lists are in `sql-editor.jsx` (`SQL_KEYWORDS`, `SQL_FUNCS`)
-— they include the ClickHouse-flavored set (e.g. `PREWHERE`, `FINAL`,
-`toStartOfMonth`, `LowCardinality`, etc.).
-
-### 5b. Editor enhancements (issues #23–#27)
-
-Reference designs for the editor-enhancement track, all built on the existing
-**textarea-over-`
`** surface (no editor library). Files: `editor-data.jsx`
-(reference data), `editor-search.jsx` (#23), `editor-complete.jsx` (#26/#27),
-and the rewired `sql-editor.jsx`. The prototype implementations are the visual
-spec; production keeps the same UX but sources data from ClickHouse system
-tables (see #25).
-
-**The keystroke rule (load-bearing):** none of these run SQL on the keystroke
-path. Autocomplete/hover/signature all read **in-memory reference data** fetched
-once per connection. Honor this in production.
-
-#### #23 — Find / replace (`editor-search.jsx`, `SearchPanel` + `findMatches`)
-- **Trigger**: `Cmd/Ctrl+F` bound on the **textarea keydown** (not a global
-  shortcut) so the browser's native find can't intercept it first.
-- **Panel**: floating, top-right of the editor. Find row = input + match counter
-  (`3/12`), prev/next, and three toggles — **Aa** case, **W** whole-word,
-  **.*** regex (active toggle filled with `--accent`). A disclosure chevron
-  expands the **Replace** row (input + Replace + Replace-all).
-- **Highlights**: drawn by a **second `color:transparent` `
` overlay**
-  (`MarkOverlay`) layered *below* the token `
`, carrying only background
-  spans — the token render path (`SqlHighlighter`) is never touched, exactly as
-  resolved in the issue. All matches use a translucent accent bg; the active
-  match a stronger accent. Same padding/font/scroll-sync as the other layers.
-- **Keys**: Enter = next, Shift+Enter = prev, Esc = close. Invalid regex →
-  counter shows "bad re", red field border, no marks.
-- **Behavior**: `findMatches(value, query, {caseSensitive, wholeWord, regex})`
-  returns `{start,end}[]`; navigation scrolls the textarea to center the active
-  match.
-
-#### #24 — Bracket matching + auto-close (`sql-editor.jsx`)
-- **Match highlight**: when the caret is adjacent to a bracket, both it and its
-  partner get an accent bg (via the same `MarkOverlay`). `matchBracketAt`
-  scans with nesting depth in either direction.
-- **Auto-close**: typing `(` `[` `{` inserts the pair and puts the caret
-  inside. Quotes `'` `"` `` ` `` auto-close too (double-quote included per the
-  resolved decision; `{`/`}` JSON context deliberately *excluded* from 1b).
-- **Wrap selection**: with text selected, typing an opener wraps the selection —
-  `(selected)`.
-- **Type-over**: typing a closing bracket/quote when the next char is already
-  that char just steps over it.
-- **Pair-delete**: Backspace inside an empty `()`/`''` removes both.
-
-#### #25 — Dynamic reference data + tokenizer API (`editor-data.jsx`)
-- **Tokenizer API**: `tokenize(sql, { keywords, funcs } = {})` — optional second
-  arg, backward-compatible (existing callers pass nothing → built-in sets). Lets
-  the server's `system.keywords` / `system.functions` drive highlighting so it's
-  version-correct.
-- **Reference payload** (`REF_KEYWORDS`, `REF_FUNCTIONS`, `REF_KEYWORD_DOCS`):
-  keyword list, function signatures + return types + descriptions, keyword docs.
-  `buildCompletions(schema)` merges these with the in-memory schema (databases,
-  tables, and **only already-loaded columns** — no on-demand column fetch from
-  the completion path) into a flat candidate list.
-- **Production**: load once per connection from `system.{keywords,functions,
-  completions,documentation}`, cache in memory for the session (localStorage
-  deferred until server-version-keyed invalidation is designed).
-
-#### #26 — Autocomplete dropdown (`editor-complete.jsx`, `AutocompleteDropdown`)
-- **Trigger**: typing word chars (≥1) or right after a `.`. `completionContext`
-  finds the word under the caret and whether it's **qualified** (`table.` →
-  only that table's columns).
-- **Ranking** (`rankCompletions`): prefix matches before substring; schema
-  (columns/tables) boosted; capped to 50. Empty word after no dot →
-  keywords + tables only.
-- **UI**: 350px popover at the caret (flips above when near the bottom). Each row
-  = a kind glyph chip (keyword `K` / function `ƒ` / aggregate `Σ` / cast `⇄` /
-  table `▦` / column `▪` / db `◈`, each color-coded), the label with the typed
-  substring bolded in accent, and a right-aligned detail (signature / type /
-  "table · N rows"). A footer shows the active item's signature → return type
-  and description.
-- **Keys**: ↑/↓ move, Enter/Tab accept, Esc dismiss; mouse click accepts.
-  Functions insert `name(`. Accepting replaces the `[from,to]` word range.
-
-#### #27 — Signature help + hover docs (`editor-complete.jsx`)
-- **Signature help**: while the caret is inside `fn(…)`, a popover above the
-  caret shows the signature with the **active argument bolded** (arg index from
-  `signatureContext`, which walks back counting commas at depth 0) and the return
-  type. Hidden while the autocomplete dropdown is open.
-- **Hover docs**: hovering a function or documented keyword (~350ms dwell) shows
-  a `HoverCard` with signature → return and description. Position is mapped from
-  mouse XY back to a token via `posFromXY` + `wordAt`. Phase 2c / optional;
-  in production source docs from `system.documentation` (load upfront with #25,
-  or lazily on first hover — open question).
-
-**Geometry note:** caret/hover positioning uses a monospace fast-path
-(`charWidthFor` via canvas + line/col arithmetic) rather than a mirror div,
-valid because the editor is `white-space: pre` in a monospace font. If a
-proportional font or wrapping is ever introduced, switch to a mirror-div
-measurement.
-
-**Not buildable on a textarea** (correctly deferred to the CodeMirror track,
-#21): code folding and multi-cursor — one caret, no line hiding.
-
----
-
-## Region 6: Results pane
-
-### 6a. Results toolbar (36px)
-
-Background `--bg-toolbar`. Bottom 1px `--border`. `0 10px` padding, 10px gap.
-
-- **View segmented control** (3 options: Table / Chart / JSON):
-  - Container: `--bg-chip`, 5px radius, 2px padding.
-  - Each segment: 22px tall, 10px x-padding, 4px radius. Active: `--bg-editor`
-    bg, `--fg` text, 500 weight, subtle 1px shadow. Inactive: `--fg-mute`, 400.
-  - Each segment shows icon + label.
-- **Spacer**
-- **Stat chips** (right-aligned, separated by 1px `--border-faint`):
-  - clock icon + ms (e.g. "218 ms")
-  - rows icon + row count (e.g. "15 rows")
-  - bytes icon + scanned bytes (e.g. "2.41 GB"), title attr shows scanned row
-    count.
-  - 11px mono, `--fg-mute` for icons, `--fg` for values.
-- **Copy button** (tb-btn): copy icon + "Copy"
-- **Export button** (tb-btn): download icon + "Export"
-
-### 6b. Empty state
-
-When no result and not running: centered column with a 36×36 `--bg-chip` circle
-holding a faded play icon, then the message "Press `⌘↵` to run query" with a
-styled kbd.
-
-### 6b-running. Query-running state — progressive streaming (no blocking loader)
-
-While a query is in flight, **do not** block the pane with a full-screen
-spinner. Instead **stream partial results into the table as they arrive** and
-show live counters in the results toolbar. Showing data-so-far is materially
-better UX than a spinner, and it mirrors how ClickHouse actually returns data.
-
-**Results toolbar while running** (replaces the static stats):
-- **Live counters**, rendered in `--accent`, mono:
-  - clock/spinner + **elapsed ms**, ticking smoothly off a local
-    `performance.now()` clock (~50ms interval).
-  - rows icon + **rows read so far** (`fmt()` → 7.7M / 64.1M-style humanized).
-  - bytes icon + **bytes scanned so far**.
-- **Cancel** button (replaces Copy/Export while running): `Icon.close` +
-  "Cancel" + an `Esc` kbd. Hover turns red (`#ef4444`). **Esc also cancels**
-  (global key handler).
-
-**Results body while running:**
-- The **table renders the partial rows** that have streamed in (columns appear
-  immediately; rows fill progressively). Before the first batch, a brief
-  centered "Starting query…" with a small spinner (`EmptyResults streaming`).
-- A 2px **streaming strip** pins to the top of the body: an `--accent` fill at
-  `read / total` when totals are known, otherwise an indeterminate sweep
-  (`runsweep` keyframes).
-
-**On cancel:** stop the stream, **keep whatever rows already arrived**, and mark
-the result `cancelled`. The toolbar then shows a red **"Cancelled · partial"**
-badge next to the (frozen) final stats, and Copy/Export re-enable on the partial
-set.
-
-**Production wiring:** drive the streamed rows + counters from ClickHouse's
-**`X-ClickHouse-Progress`** headers (rows/bytes read + total estimate) and the
-streamed result body; wire **Cancel** / Esc to **`KILL QUERY`**. The prototype
-simulates the stream in `app.jsx` → `runQuery` (partial-row slices on a timer)
-and `cancelQuery`. A **"Slow query (~9s)"** toggle under Tweaks → Demo only
-slows the simulation so the streaming is easy to observe — no production
-meaning; remove it in the real build.
-
-### 6c. Table view
-
-- Mono font, 11.5px.
-- `border-collapse: collapse`. Width `max-content`, min `100%`.
-- **Header row** (`thead` is `position: sticky; top: 0`):
-  - 36px wide `#` column, centered, `--fg-faint`.
-  - Each data column: min-width 140px. Cell padding `7px 12px` (4px 10px in
-    compact). Background `--bg-th`. Font 11px / 500 / `--fg-mute`.
-  - Inside each cell: column name in `--fg`, then type badge in 9.5px /
-    `--fg-faint`. Spacer. If this column is the active sort, sort arrow in
-    `--accent`. Click toggles asc → desc → asc.
-- **Data row**:
-  - Hover: `--bg-hover` on every cell.
-  - Number cells: right-aligned, color `--num` (`#92E1D8` dark / `#0F766E`
-    light), shown to 2 decimals.
-  - String cells: left-aligned, `--fg`.
-  - **Special case**: column 0 in the demo result is an airline code like
-    "B6". Render as `B6` followed by faded carrier name (`JetBlue`,
-    etc) in `--fg-faint`. The lookup table is in `data.jsx` (`CARRIER_NAMES`).
-    In production, this should be a more general "dimension display" extension
-    (e.g. allow saved queries to declare lookup mappings).
-  - All cells: 1px `--border-faint` right + bottom borders.
-
-### 6d. Chart view (bar)
-
-For 2-column results where col 0 is a dimension and col 1 is a number.
-
-- Padding `20px 24px`. Background `--bg-table`.
-- Title: "{col1.name} by {col0.name}", 11px mono / `--fg-mute`, 14px bottom.
-- Each row, 18px tall:
-  - Label cell: 110px wide, mono, right-aligned. Code in `--fg`, expanded name
-    in `--fg-faint` (same dimension treatment as the table).
-  - Bar track: `flex: 1`, 18px tall, `--bg-chip` bg, 2px radius.
-  - Bar fill: gradient `linear-gradient(90deg, var(--accent),
-    color-mix(in oklab, var(--accent) 65%, transparent))`. Width = (value /
-    max) * 100%. Transition `width .4s cubic-bezier(.2,.7,.3,1)`.
-  - Value cell: 70px wide, mono, right-aligned, `--num`, 2 decimals.
-
-For other shapes (single-series line, pie, multi-series), follow the same
-visual language: accent-tinted, mono labels, dim grid, no chartjunk. Use
-visx / Recharts / d3 / Apache ECharts — whatever the codebase has.
-
-#### Implementing Chart view in production (answer to Boris)
-
-> **Now built in the prototype.** `ResultsChart` in `components.jsx` implements
-> the config bar + `autoChart()` defaults + SVG renderers + an HTML horizontal-bar
-> renderer (Bar=horizontal/Column=vertical/Line/Area/Pie, multi-series, group-by).
-> `autoChart` defaults categorical X → **horizontal Bar** (the ranked-list view
-> from the first design — best for category comparisons), temporal X → Line,
-> ordinal X → Column. `data.jsx` adds `RESULT_MONTHLY` (temporal → Line) and
-> `RESULT_DOW` (ordinal) demo sets, and `pickResult(sql)` chooses one by
-> inspecting the SQL so Bar↔Line is demonstrable. The renderers are
-> prototype-grade — **swap for a real charting library in production** (below);
-> keep the config-bar UX, the `autoChart` heuristic, and horizontal-bar default.
-
-The prototype's chart is deliberately the *minimum*: a CSS-only horizontal bar
-chart hardwired to `col[0]=dimension, col[1]=measure` (see `ResultsChart` in
-`components.jsx`). It demonstrates the look, not the real capability. Here's how
-to build the production version.
-
-**1. Don't hand-roll it — use a charting library.** CSS bars don't scale to
-line/area/pie, axes, tooltips, legends, log scales, or thousands of points.
-Pick one already in (or acceptable to) the codebase:
-- **Apache ECharts** — best for large/dense data and many chart types
-  (canvas-rendered, handles 10k+ points, built-in zoom/tooltip). Recommended
-  default for a data tool.
-- **Recharts / visx** — fine if the app is React-first and datasets stay small
-  (SVG; gets heavy past a few thousand points).
-- **Observable Plot** — terse grammar-of-graphics, great for quick exploratory
-  charts.
-
-**2. Infer column roles from ClickHouse types, then let the user override.**
-The result already carries `{name, type}` per column — use the type to
-classify, don't guess from values:
-- **Measures (Y / value)**: numeric types — `Int*`, `UInt*`, `Float*`,
-  `Decimal*`.
-- **Temporal (X, ordered)**: `Date`, `Date32`, `DateTime`, `DateTime64`.
-- **Dimensions (X / category / series)**: `String`, `LowCardinality(String)`,
-  `Enum*`, `Bool`.
-- Strip `Nullable(...)` / `LowCardinality(...)` wrappers before classifying.
-
-Auto-pick a sensible default encoding (first temporal or dimension → X; first
-measure → Y), then expose a small **chart-config bar** above the plot so the
-user can change it. That config is the real feature:
-- **Chart type**: Bar / Line / Area / Pie / (Scatter). Default by data shape:
-  temporal X → line; categorical X → bar; single dimension + single measure and
-  ≤ ~12 rows → pie is allowed.
-- **X axis** (dropdown of columns), **Y axis / measures** (one or many numeric
-  columns → multi-series), optional **Series/Group-by** (a dimension column →
-  splits into multiple lines/stacked bars).
-- Persist the chosen config per query tab.
-
-**3. Map result → chart data.** Transform the `rows: any[][]` + `columns[]` into
-the library's series format using the encoding above. For multi-series, pivot on
-the series column. Coerce `DateTime` strings to real dates for time axes.
-
-**4. Theme it to the tokens** so charts match the app in both themes:
-- Series color: `--accent` (single series); for multi-series derive a small
-  palette by rotating hue off the accent (e.g. OKLCH hue steps) rather than a
-  random rainbow.
-- Axes / grid: `--border` / `--border-faint`; labels `--fg-mute`, mono font
-  (`--mono`); tooltip surface `--bg-modal` + `--border`.
-- No gradients-as-decoration, no drop shadows, no 3D — keep the dense/technical
-  look.
-
-**5. Handle the realities of query output:**
-- **No chartable columns** (e.g. all strings, or a single column) → show an
-  empty-state hint ("Add a numeric column to chart these results"), not a broken
-  axis.
-- **Too many points** → the chart engine should downsample/aggregate, or prompt
-  to add `LIMIT` / `GROUP BY`. ECharts' `large` mode or server-side bucketing
-  handles this.
-- **Streaming**: only render the chart on completed (or paused) result sets;
-  re-rendering a chart on every streamed batch is wasteful — update the table
-  live, build the chart when the stream settles.
-- Respect the current sort, and format numbers like the table (`--num`, 2
-  decimals / humanized).
-
-**6. Keep the toggle contract.** Chart is one of the three result views
-(Table / Chart / JSON segmented control). Selecting it swaps the body only; the
-results toolbar (stats, copy/export) stays. "Export" on a chart view can offer
-PNG/SVG in addition to the data formats.
-
-### 6e. JSON view
-
-- `
` over the full pane, padding `14px 16px`.
-- 11.5px mono, `--fg`, on `--bg-table`. Pretty-printed (2-space indent).
-- Built from the sorted rows + column names.
-
----
-
-## Region 7: Tweaks panel (dev tool — DO NOT SHIP TO END USERS)
-
-This is a design-time controls panel from our prototyping kit. It exists so the
-designer can tweak theme/accent/density/sidebar live. It's **not part of the
-end-user product**.
-
-If you want a similar end-user "preferences" surface, that's a separate spec —
-ask before adding.
-
----
-
-## Region 8: Shortcuts modal (`?` to open)
-
-- 480px wide centered modal, `--bg-modal` background, 1px `--border`, 10px
-  radius. Backdrop: `rgba(0,0,0,.5)` + 4px blur.
-- Title "Keyboard shortcuts", 14px / 600.
-- 3 groups (Editor, Navigation, Results) — each a small section header (10px /
-  600 / uppercase / `.06em` letter-spacing / `--fg-faint`) followed by rows.
-- Row: label in `--fg-mute` left, kbd badge right (10.5px mono, `--bg-chip`
-  bg, 4px radius, `--fg`).
-- Click outside closes. The exact shortcut list is in `components.jsx`
-  → `ShortcutsModal`.
-
----
-
-## Interactions & behavior
-
-- **⌘↵ / Ctrl↵**: run query.
-- **⌘T / Ctrl T**: new tab.
-- **⌘W / Ctrl W**: close tab. (Wired to UI close × button; bind globally.)
-- **?**: toggle shortcuts modal (only when not in input/textarea).
-- **Click column in schema** → inserts column name at end of active tab's SQL.
-  In production, prefer "insert at cursor" via the editor's native API.
-- **Click table row in saved/history** → opens the SQL as a new tab.
-- **Tab dirty state**: any edit since load → small dot next to the tab name.
-  Save logic isn't designed yet — saved queries are a read-only catalog in
-  the prototype. Define save UX with the team.
-- **Run query** flow in the prototype just sleeps 600ms and returns the canned
-  result. In production, post to the ClickHouse HTTP interface
-  (`POST /?database=…` with `X-ClickHouse-Format` header) and stream/parse
-  results. Show running spinner state on the Run button (already wired —
-  `running={running}` prop).
-- **Sort columns**: clicking a header cycles asc → desc → asc (no neutral
-  state in the prototype). For real datasets larger than what's loaded,
-  re-issue the query with `ORDER BY` rather than client-sorting.
-- **Splitter drags**: editor/results split clamps to 15–85%. Sidebar width
-  clamps 180–420.
-- **Resizable sidebar inner split** (schema vs saved/history) — currently
-  uses flex; consider making it draggable too if users ask.
-- **Search in schema**: filters by substring across table and column names.
-  Tables that don't match but have matching columns stay visible (auto-expand).
-  Persist? — open question.
-
----
-
-## Design Tokens
-
-All theme tokens live as CSS custom properties on the `[data-theme='dark']` /
-`[data-theme='light']` selectors. They drive every surface, border, and
-text color in the design.
-
-### Dark theme (default)
-
-| Token | Value | Purpose |
-|-------|-------|---------|
-| `--bg`           | `#0E0E10` | App background |
-| `--bg-header`    | `#131316` | Header & sidebar bg |
-| `--bg-side`      | `#131316` | Sidebar bg |
-| `--bg-tabs`      | `#131316` | Tabs row |
-| `--bg-toolbar`   | `#15151A` | Editor + results toolbars |
-| `--bg-editor`    | `#0E0E10` | Editor pane |
-| `--bg-gutter`    | `#131316` | Editor line numbers |
-| `--bg-table`     | `#0E0E10` | Results surface |
-| `--bg-th`        | `#15151A` | Table header |
-| `--bg-input`     | `#1A1A20` | Inputs |
-| `--bg-chip`      | `#1F1F26` | Chips, segmented control track |
-| `--bg-hover`     | `rgba(255,255,255,.04)` | Generic hover |
-| `--bg-highlight` | `rgba(255,107,53,.08)` | Search match |
-| `--bg-modal`     | `#1A1A20` | Modal surface |
-| `--fg`           | `#E6E6E8` | Primary text |
-| `--fg-mute`      | `#A0A0A8` | Secondary text |
-| `--fg-faint`     | `#6B6B74` | Tertiary text / icons |
-| `--num`          | `#92E1D8` | Numeric values in tables/charts |
-| `--border`       | `#1F1F26` | Hard borders |
-| `--border-faint` | `#1A1A20` | Soft cell/row dividers |
-
-### Light theme
-
-| Token | Value |
-|-------|-------|
-| `--bg`           | `#FAFAFA` |
-| `--bg-header`    | `#FFFFFF` |
-| `--bg-side`      | `#F5F5F4` |
-| `--bg-tabs`      | `#F5F5F4` |
-| `--bg-toolbar`   | `#FAFAF9` |
-| `--bg-editor`    | `#FFFFFF` |
-| `--bg-gutter`    | `#FAFAF9` |
-| `--bg-table`     | `#FFFFFF` |
-| `--bg-th`        | `#F5F5F4` |
-| `--bg-input`     | `#FFFFFF` |
-| `--bg-chip`      | `#EEECE8` |
-| `--bg-hover`     | `rgba(0,0,0,.04)` |
-| `--bg-highlight` | `rgba(255,107,53,.12)` |
-| `--bg-modal`     | `#FFFFFF` |
-| `--fg`           | `#1A1A1F` |
-| `--fg-mute`      | `#57575E` |
-| `--fg-faint`     | `#94949C` |
-| `--num`          | `#0F766E` |
-| `--border`       | `#E5E3DE` |
-| `--border-faint` | `#EEECE7` |
-
-### Accent
-
-`--accent` is a **theme-independent** brand color — same in dark and light:
-
-- **Default (Altinity orange)**: `#FF6B35`
-- Quick swatches in the design: `#FF6B35`, `#F0A500`, `#FFC700`, `#3B82F6`,
-  `#10B981`, `#EC4899`. The color picker stores the value; UI uses it
-  everywhere — Run button, table top accent, sort arrow, chart bars, search
-  highlight, logo gradient base.
-
-### Typography
-
-| Family | CSS |
-|---|---|
-| UI    | `'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif` |
-| Mono  | `'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace` |
-
-Type ramp (px / weight):
-- Modal title: 14 / 600
-- Sidebar header rows / tab name: 13 / 600 / 11.5 / 500
-- Body button: 11.5 / 500–600
-- Secondary text: 11 / 400
-- Small caps section labels: 10 / 600 / uppercase / `.06em` letter-spacing
-- Table cells / SQL editor: 11.5–13 mono / 400
-
-### Spacing & radii
-
-- Heights: header 44, tabs 34, toolbars 36–38, control buttons 26, segmented
-  control items 22, tree rows 22–24.
-- Padding: page rails `0 14px`, region toolbars `0 10px`, table cells
-  `7px 12px` comfortable / `4px 10px` compact.
-- Radii: 4 (small chips, kbd), 5 (buttons, inputs, selects), 10 (modal), 12
-  (avatar circle).
-- Shadows: very restrained. The active segment in the segmented control gets
-  `0 1px 2px rgba(0,0,0,.15)`. The modal: `0 20px 60px rgba(0,0,0,.4)`. The
-  status dot: `0 0 6px #22c55e`.
-
-### Density
-
-`compact` reduces editor line-height (1.7→1.5) and font (13→12.5), tabs row
-(34→28), and table cell padding (7×12 → 4×10). `comfortable` is default.
-
----
-
-## State Management
-
-Minimal, all local:
-
-- `tabs: Tab[]` — open query tabs (id, name, sql, dirty).
-- `activeId` — id of focused tab.
-- `result` — current result-set (canned in prototype). In production: tied
-  to the active tab; cache last result per tab so switching tabs doesn't lose
-  it.
-- `running: bool` — query in flight.
-- `shortcutsOpen` — modal toggle.
-- Pane sizes — `editorPct` (vertical split %), `sidebarPx` (sidebar width).
-
-Persisting to URL/local storage suggestions:
-
-- The currently-loaded SQL → URL hash (so a query is shareable). The "Share"
-  button should generate this URL.
-- Sidebar/editor split sizes → localStorage.
-- Theme/density/accent → localStorage (or user account settings).
-
-## Data fetching
-
-Replace `runQuery` (currently a 600ms timeout) with the actual ClickHouse HTTP
-call:
-
-```
-POST {clickhouse-base}/?database={db}&default_format=JSONCompactEachRowWithNamesAndTypes
-Authorization: Basic …  // or whatever the existing /play uses
-Content-Type: text/plain
-
-{sql}
-```
-
-`JSONCompactEachRowWithNamesAndTypes` returns a streaming format that's easy
-to render into the table without re-parsing. The result-set shape used by
-the prototype (`{ columns: [{name, type}], rows: any[][], meta: {ms, rows,
-scanned, scannedRows}}`) maps cleanly: take the first two streamed objects
-as names + types, the rest as rows, and read the `X-ClickHouse-Summary`
-response header for stats.
-
----
-
-## Schema for the "schema browser" data
-
-Currently hardcoded in `data.jsx` (`SCHEMA`). In production, fetch from
-ClickHouse:
-
-```sql
-SELECT database, name, total_rows, total_bytes
-FROM system.tables
-WHERE database NOT IN ('INFORMATION_SCHEMA', 'information_schema')
-ORDER BY database, name
-
--- and for columns when a table is expanded:
-SELECT name, type
-FROM system.columns
-WHERE database = ? AND table = ?
-ORDER BY position
-```
-
-Lazy-load columns on table expand; cache per session.
-
----
-
-## Saved queries & history
-
-The prototype hardcodes both. In production:
-
-- **Saved queries**: persist per-user to wherever Altinity Antalya stores user
-  state. Schema: `{id, name, sql, starred, created_at, updated_at}`.
-- **History**: write every executed query to a per-user log (capped, e.g. last
-  500) with `{sql, started_at, duration_ms, rows, error}`.
-
-> **Note on the current implementation:** saved queries live in browser
-> `localStorage` today. That makes them per-browser-profile — lost on a cache
-> clear, invisible on the user's other devices, and unshareable. The
-> export/import feature below is the agreed interim mitigation; account-backed
-> server storage is the eventual answer (and even then, export survives as a
-> backup / portability / no-lock-in feature, so this work is not throwaway).
-
-### Export / Import saved queries (JSON)
-
-**Goal:** let users back up, transfer between machines/browsers, and share
-their saved-query library. **JSON is the canonical format** — it round-trips
-losslessly (export → import reproduces the library exactly).
-
-(Decisions already made with the team: **JSON only** for round-trip
-import/export. Markdown may be added later as an *export-only* "share" format.
-CSV/TSV were explicitly rejected — SQL payloads contain newlines/commas/quotes
-that delimited formats handle badly.)
-
-#### File envelope
-
-Export the whole library (or a user-selected subset) as a single `.json` file.
-Wrap the array in a versioned envelope so the format can evolve:
-
-```json
-{
-  "format": "altinity-sql-browser/saved-queries",
-  "version": 1,
-  "exportedAt": "2026-06-21T17:52:53.000Z",
-  "queries": [
-    {
-      "id": "q_8f3a1c",
-      "name": "Worst-delay carriers (2023)",
-      "sql": "SELECT Reporting_Airline, avg(DepDelayMinutes) AS avg_delay\nFROM airline.ontime\nWHERE Year = 2023 AND Cancelled = 0\nGROUP BY Reporting_Airline\nORDER BY avg_delay DESC\nLIMIT 15",
-      "starred": true,
-      "createdAt": "2026-05-02T09:14:00.000Z",
-      "updatedAt": "2026-06-10T12:31:00.000Z"
-    }
-  ]
-}
-```
-
-- `format` + `version` let the importer reject foreign/garbage files and
-  migrate older exports. Bump `version` on any breaking schema change.
-- `id` should be **stable** (don't regenerate on every save) — it's what makes
-  re-import idempotent (see merge rules).
-- Suggested filename: `sql-browser-queries-YYYY-MM-DD.json`.
-
-#### Export UI
-
-- Primary: **Export all** → downloads the envelope above.
-- Nice-to-have: multi-select in the Saved list → **Export selected**. Same
-  envelope, filtered `queries[]`.
-- **Placement**: Export + Import are a two-button row **pinned at the bottom of
-  the Saved panel** (top border, `flex-shrink: 0`), below the scrolling query
-  list — not at the top. The import-result toast ("Added N · updated N ·
-  skipped N") appears just above the bar.
-- Implementation: serialize → `Blob` → `URL.createObjectURL` → anchor download.
-  No backend needed.
-
-#### Import UI + merge rules (this is the real design work)
-
-Export is trivial; **import is where the decisions live.** On file pick:
-
-1. **Validate** — parse JSON; reject if `format` doesn't match or `version` is
-   newer than the app understands. Validate each query's shape. Cap count/size
-   (e.g. ≤ 1000 queries, ≤ 1 MB) to avoid abuse.
-2. **Treat SQL as untrusted text** — never auto-run an imported query. It only
-   ever runs later when the user explicitly hits Run.
-3. **Collision handling** — for each incoming query, match against the existing
-   library **by `id`**:
-   - *No match* → add as new.
-   - *Match, identical `sql` + `name`* → skip (no-op; makes re-import
-     idempotent).
-   - *Match, differs* → resolve via the user's chosen strategy. Offer at minimum:
-     **Skip**, **Overwrite**, **Keep both** (import gets a new id +
-     "(imported)" suffix on the name). A per-conflict prompt is ideal; a
-     single global choice is the acceptable MVP.
-   - If `id`s aren't trustworthy across installs, fall back to matching on a
-     **hash of normalized SQL**, or on `name`.
-4. **Partial import** — show a preview list with checkboxes so users import a
-   subset, not all-or-nothing. MVP can skip this and import everything.
-5. **Report** — after import, summarize: "Added 6, updated 2, skipped 3."
-
-#### Markdown export (future, export-only)
-
-If/when a "share" export is added: render each query as a `## {name}` heading
-followed by a fenced ` ```sql ` block — renders perfectly in GitHub/wikis as a
-"query cookbook." **Do not** rely on parsing it back; metadata (starred,
-timestamps) doesn't survive without per-query YAML frontmatter, at which point
-JSON is the better round-trip format. Keep Markdown strictly one-directional.
-
----
-
-## Assets
-
-- **Inter** and **JetBrains Mono** fonts loaded from Google Fonts in the
-  prototype. Self-host in production for performance/privacy.
-- **Icons** are inline SVGs in `components.jsx` → `Icon` map. Replace with
-  the codebase's icon library (lucide / phosphor / heroicons / etc.) using
-  the closest equivalents — they're all standard glyphs (chevron, database,
-  table, columns, play, star, plus, close, search, history, download, share,
-  copy, sortAsc, sortDesc, filter, clock, rows, bytes, etc.).
-
----
-
-## Files in this bundle
-
-- `Altinity Play.html` — entry point. Open in a browser to see the design.
-- `Login.html` — the sign-in / connection screen (SSO + credentials + optional
-  host:port override). Self-contained; imports `tweaks-panel.jsx`.
-- `app.jsx` — top-level `` component, layout assembly, splitters,
-  global keyboard handlers.
-- `components.jsx` — header, schema tree, saved/history panel, query tabs,
-  editor toolbar, results pane (table/chart/json), shortcuts modal, icon
-  set.
-- `sql-editor.jsx` — the syntax-highlighted SQL editor (textarea over `
`)
-  + the editor enhancements (#23–#27): tokenizer dynamic-keyword API, bracket
-  matching/auto-close, find/replace wiring, autocomplete + signature + hover
-  wiring, caret geometry. **In production, the editing surface can stay as-is
-  for #23–#27; folding/multi-cursor need CodeMirror (#21).** Keep the visual
-  treatment (colors, gutter, font).
-- `editor-data.jsx` — reference data (keywords, function signatures/docs,
-  `buildCompletions`). Load from ClickHouse system tables in production (#25).
-- `editor-search.jsx` — find/replace panel + `findMatches` (#23).
-- `editor-complete.jsx` — autocomplete dropdown, signature help, hover card,
-  and their context/ranking helpers (#26/#27).
-- `data.jsx` — sample schema, saved queries, history, and a canned result-set
-  (worst-delay carriers query against the airline ontime dataset).
-- `tweaks-panel.jsx` — design-time controls. **Not part of the end-user
-  product.**
-
----
-
-## Resolved since first handoff
-
-Decisions made with the team after the initial spec (implemented in the live
-app — recorded here so the README stays the source of truth):
-
-- **Save UX** — "Save" button in the editor toolbar (+ ⌘S) opens a name
-  popover; saved items appear in the ★ Saved list with inline rename (pencil),
-  delete (trash), and star toggle. Implemented.
-- **Format button** — pretty-prints SQL (⌘⇧F). Prototype uses a hand-rolled
-  formatter; production should use `sql-formatter` or the editor's native
-  format action.
-- **Column resize** — implemented in the live app.
-- **Column types in result header** — **removed by design.** Stored-column
-  types already live in the schema browser, so repeating them in the result
-  header is duplication. Trade-off: **computed/aliased columns**
-  (`avg(...) AS x`, `count()`, JOIN outputs) have their type nowhere else —
-  recommend exposing type on **hover** of the result column name to cover that
-  case without re-adding the duplication.
-- **GitHub link** — added to the header. Give it `aria-label="View source on
-  GitHub"` and `target="_blank" rel="noopener"`.
-- **User menu / Log Out** — header avatar is a button opening a dropdown
-  (identity + role + red Log out), which raises a confirmation dialog. See
-  Region 1 for the full spec.
-- **Export/Import placement** — the two-button row is pinned at the **bottom**
-  of the Saved panel, below the list.
-- **Markdown "Publish"** — deferred for more thought; captured as a separate
-  proposed issue (`ISSUE-publish-as-markdown.md` in this bundle).
-- **Saved-query export/import** — JSON, spec'd in "Export / Import saved
-  queries" above.
-
----
-
-## Open questions for the design + product team
-
-(These came up while building the prototype / reviewing the live app and
-weren't fully resolved.)
-
-1. **`content`-style blob columns**: text cells holding large values (full HTML
-   documents, long JSON) are unreadable inline even with column resize. Add a
-   **cell-detail drawer**: click a cell → side panel/modal with the full value,
-   pretty-printed, and a **rendered-vs-source toggle** for HTML. Pair with
-   `max-width` + ellipsis truncation on text cells. **Highest-impact open item.**
-2. **Sticky first column(s)**: freeze `#` (and ideally the first data column)
-   during horizontal scroll so row identity isn't lost when reading wide
-   columns to the right.
-3. **NULL rendering**: render `NULL` distinctly (faint italic "null"), never as
-   an empty cell — otherwise NULL is indistinguishable from an empty string,
-   which matters on a tool people use to learn unfamiliar data.
-4. **Long version string in header**: e.g.
-   "ClickHouse 26.3.10.20001.altinityantalya" crowds the top bar and will
-   overflow on narrow widths. Truncate (e.g. `26.3.10`) with the full string on
-   hover.
-5. **Saved-query storage**: `localStorage` today (per-browser, fragile). JSON
-   export/import is the interim mitigation; account-backed server sync is the
-   roadmap answer (and unlocks real shared-query URLs via the existing Share
-   button).
-6. **Tab persistence**: should open tabs + their SQL survive a refresh? (Likely
-   yes — localStorage.)
-7. **Query cancellation**: ClickHouse supports `KILL QUERY`. Surface an inline
-   "Cancel" affordance on the running button?
-8. **Streaming results**: large result-sets — paginate, or virtual-scroll the
-   whole thing? Recommend virtual scroll (TanStack Virtual / react-window).
-9. **Errors**: error UI isn't in the prototype. Treat the result pane as the
-   surface (red banner + traceback in mono).
-10. **Auth**: the original page is auth-gated. Login screen design wasn't in
-    scope for this round. Coordinate with whoever owns it.
-11. **Accessibility**: contrast in dark mode is good (Inter @ `#E6E6E8` on
-    `#0E0E10` ≈ 14:1). Audit segmented control + chart bars in light mode.
-    Wire keyboard nav for the schema tree (↑↓ to move, → to expand). The
-    shortcuts modal needs a real `role="dialog"` with focus trap.
diff --git a/design/app.jsx b/design/app.jsx
deleted file mode 100644
index 8ae9676..0000000
--- a/design/app.jsx
+++ /dev/null
@@ -1,340 +0,0 @@
-// app.jsx — main app shell
-
-function App() {
-  const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
-  const accent = t.accent;
-  const dark = t.theme === 'dark';
-  const density = t.density;
-  const sidebarVisible = t.sidebar;
-
-  // Tabs
-  const [tabs, setTabs] = React.useState([
-    { id: 't1', name: 'Worst-delay carriers', sql: SAVED_QUERIES[0].sql, dirty: false, savedId: SAVED_QUERIES[0].id },
-    { id: 't2', name: 'Untitled query', sql: 'SELECT count() FROM airline.ontime\nWHERE Year = 2023', dirty: true },
-  ]);
-  const [activeId, setActiveId] = React.useState('t1');
-  const active = tabs.find(t => t.id === activeId) || tabs[0];
-
-  const [result, setResult] = React.useState(RESULT_DELAYS);
-  const [running, setRunning] = React.useState(false);
-  const [shortcutsOpen, setShortcutsOpen] = React.useState(false);
-  const [savedQueries, setSavedQueries] = React.useState(SAVED_QUERIES);
-  const [saveSignal, setSaveSignal] = React.useState(0);
-
-  const updateTabSql = (sql) => {
-    setTabs(ts => ts.map(t => t.id === activeId ? { ...t, sql, dirty: true } : t));
-  };
-  const newTab = () => {
-    const id = 't' + Date.now();
-    setTabs(ts => [...ts, { id, name: 'Untitled query', sql: '', dirty: false }]);
-    setActiveId(id);
-  };
-  const closeTab = (id) => {
-    setTabs(ts => {
-      const i = ts.findIndex(t => t.id === id);
-      const next = ts.filter(t => t.id !== id);
-      if (id === activeId) setActiveId(next[Math.max(0, i - 1)].id);
-      return next;
-    });
-  };
-  const loadQuery = (q) => {
-    const id = 't' + Date.now();
-    setTabs(ts => [...ts, { id, name: q.name, sql: q.sql, dirty: false, savedId: q.id }]);
-    setActiveId(id);
-  };
-  const insertColumn = (col) => {
-    setTabs(ts => ts.map(t => t.id === activeId ? { ...t, sql: t.sql + col, dirty: true } : t));
-  };
-  const formatCurrent = () => {
-    setTabs(ts => ts.map(t => t.id === activeId ? { ...t, sql: formatSql(t.sql), dirty: true } : t));
-  };
-  const saveCurrentQuery = (name) => {
-    const sql = active.sql;
-    const existingId = active.savedId && savedQueries.some(q => q.id === active.savedId) ? active.savedId : null;
-    const id = existingId || ('s' + Date.now());
-    setSavedQueries(qs => existingId
-      ? qs.map(q => q.id === id ? { ...q, name, sql } : q)
-      : [{ id, name, sql, starred: false }, ...qs]);
-    setTabs(ts => ts.map(t => t.id === activeId ? { ...t, name, dirty: false, savedId: id } : t));
-  };
-  const renameSaved = (id, name) => {
-    setSavedQueries(qs => qs.map(q => q.id === id ? { ...q, name } : q));
-    setTabs(ts => ts.map(t => t.savedId === id ? { ...t, name } : t));
-  };
-  const deleteSaved = (id) => {
-    setSavedQueries(qs => qs.filter(q => q.id !== id));
-    setTabs(ts => ts.map(t => t.savedId === id ? { ...t, savedId: undefined, dirty: true } : t));
-  };
-  const toggleStar = (id) => {
-    setSavedQueries(qs => qs.map(q => q.id === id ? { ...q, starred: !q.starred } : q));
-  };
-  const importQueries = (incoming) => {
-    let added = 0, updated = 0, skipped = 0;
-    setSavedQueries(qs => {
-      const next = [...qs];
-      const byId = new Map(next.map((q, i) => [q.id, i]));
-      for (const q of incoming) {
-        const existingIdx = q.id != null ? byId.get(q.id) : undefined;
-        if (existingIdx == null) {
-          // new query — keep its id if free, else mint one
-          const id = (q.id != null && !byId.has(q.id)) ? q.id : ('s' + Date.now() + Math.random().toString(36).slice(2, 6));
-          const rec = { id, name: q.name, sql: q.sql, starred: !!q.starred };
-          byId.set(id, next.length); next.push(rec); added++;
-        } else {
-          const cur = next[existingIdx];
-          if (cur.sql === q.sql && cur.name === q.name) { skipped++; }
-          else {
-            // collision with differing content → keep both (import gets a new id)
-            const id = 's' + Date.now() + Math.random().toString(36).slice(2, 6);
-            next.push({ id, name: q.name + ' (imported)', sql: q.sql, starred: !!q.starred });
-            added++;
-          }
-        }
-      }
-      return next;
-    });
-    return { added, updated, skipped };
-  };
-  const [progress, setProgress] = React.useState(null);
-  const runTimers = React.useRef([]);
-  const clearRunTimers = () => { runTimers.current.forEach(clearTimeout); runTimers.current = []; };
-
-  const runQuery = () => {
-    clearRunTimers();
-    setRunning(true);
-    const final = pickResult(active.sql);
-    // Simulate ClickHouse streaming: partial rows arrive while rows/bytes-read
-    // counters climb toward an estimated total (X-ClickHouse-Progress). Showing
-    // partial data beats a blocking spinner. Replace with real streamed parse.
-    const TOTAL = 64_100_000;
-    const allRows = final.rows;
-    const steps = [0.12, 0.3, 0.52, 0.71, 0.88, 1];
-    // Demo timing only — "Slow query" tweak stretches it so streaming is easy
-    // to observe. No production meaning.
-    const stepMs = t.slowQuery ? 1500 : 280;
-    const fmt = (n) => n >= 1e6 ? (n/1e6).toFixed(1)+'M' : n >= 1e3 ? (n/1e3).toFixed(0)+'K' : String(n);
-    setResult({ ...final, rows: [], partial: true,
-      meta: { rows: 0, ms: 0, scanned: '0 B', scannedRows: '0' } });
-    setProgress({ read: 0, total: TOTAL, bytes: '0 B' });
-    steps.forEach((frac, i) => {
-      runTimers.current.push(setTimeout(() => {
-        const nRows = Math.round(allRows.length * frac);
-        const read = Math.round(TOTAL * frac);
-        const bytes = (frac * 2.41).toFixed(2) + ' GB';
-        setProgress({ read, total: TOTAL, bytes });
-        setResult({ ...final, rows: allRows.slice(0, nRows), partial: frac < 1,
-          meta: { rows: nRows, ms: Math.round(stepMs * (i + 1)), scanned: bytes, scannedRows: fmt(read) } });
-      }, stepMs * (i + 1)));
-    });
-    runTimers.current.push(setTimeout(() => {
-      setResult(final);
-      setRunning(false);
-      setProgress(null);
-    }, stepMs * (steps.length + 1)));
-  };
-  const cancelQuery = () => {
-    clearRunTimers();
-    setRunning(false);
-    setProgress(null);
-    // Keep whatever streamed in, but mark it cancelled (partial + a flag the
-    // results pane surfaces). Production: also issue KILL QUERY.
-    setResult(r => r ? { ...r, partial: false, cancelled: true } : r);
-  };
-
-  React.useEffect(() => {
-    const onKey = (e) => {
-      if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
-        e.preventDefault(); runQuery();
-      }
-      if ((e.metaKey || e.ctrlKey) && e.key === 't') {
-        e.preventDefault(); newTab();
-      }
-      if ((e.metaKey || e.ctrlKey) && e.key === 's') {
-        e.preventDefault(); setSaveSignal(s => s + 1);
-      }
-      if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === 'F' || e.key === 'f')) {
-        e.preventDefault(); formatCurrent();
-      }
-      if (e.key === '?' && !['INPUT','TEXTAREA'].includes(e.target.tagName)) {
-        setShortcutsOpen(o => !o);
-      }
-      if (e.key === 'Escape' && running) {
-        e.preventDefault(); cancelQuery();
-      }
-    };
-    document.addEventListener('keydown', onKey);
-    return () => document.removeEventListener('keydown', onKey);
-  });
-
-  // Editor / results split
-  const [editorPct, setEditorPct] = React.useState(45);
-  const splitRef = React.useRef(null);
-  const onSplitDrag = (e) => {
-    e.preventDefault();
-    const onMove = (ev) => {
-      const r = splitRef.current.getBoundingClientRect();
-      const pct = ((ev.clientY - r.top) / r.height) * 100;
-      setEditorPct(Math.max(15, Math.min(85, pct)));
-    };
-    const onUp = () => {
-      window.removeEventListener('mousemove', onMove);
-      window.removeEventListener('mouseup', onUp);
-    };
-    window.addEventListener('mousemove', onMove);
-    window.addEventListener('mouseup', onUp);
-  };
-
-  const [sidebarPx, setSidebarPx] = React.useState(248);
-  const onSidebarDrag = (e) => {
-    e.preventDefault();
-    const onMove = (ev) => setSidebarPx(Math.max(180, Math.min(420, ev.clientX)));
-    const onUp = () => {
-      window.removeEventListener('mousemove', onMove);
-      window.removeEventListener('mouseup', onUp);
-    };
-    window.addEventListener('mousemove', onMove);
-    window.addEventListener('mouseup', onUp);
-  };
-
-  return (
-    
- setShortcutsOpen(true)} /> - -
- {/* Sidebar */} - {sidebarVisible && ( - <> -
-
- -
-
-
- -
-
-
- - )} - - {/* Main column */} -
- - {}} - onSave={saveCurrentQuery} - currentName={active.name} - isSaved={!!active.savedId && !active.dirty} - saveSignal={saveSignal} - /> -
- -
-
-
-
-
- -
-
-
- - setShortcutsOpen(false)} /> - - - - setTweak('theme', v)} /> - setTweak('accent', v)} /> -
- {['#FF6B35', '#F0A500', '#FFC700', '#3B82F6', '#10B981', '#EC4899'].map(c => ( -
-
- - setTweak('density', v)} /> - setTweak('sidebar', v)} /> - - - setTweak('slowQuery', v)} /> - -
-
- ); -} - -Object.assign(window, { App }); diff --git a/design/components.jsx b/design/components.jsx deleted file mode 100644 index 16a05f2..0000000 --- a/design/components.jsx +++ /dev/null @@ -1,1429 +0,0 @@ -// components.jsx — schema browser, results pane, header, history/saved - -// ─── ICONS ──────────────────────────────────────────────────────────── -const Icon = { - chev: (props) => , - chevDown: (props) => , - database: (props) => , - table: (props) => , - col: (props) => , - play: (props) => , - star: (filled, props={}) => , - plus: (props) => , - close: (props) => , - search: (props) => , - history: (props) => , - download: (props) => , - share: (props) => , - copy: (props) => , - table2: (props) => , - chart: (props) => , - json: (props) => , - sortAsc: (props) => , - sortDesc: (props) => , - filter: (props) => , - shortcuts: (props) => , - clock: (props) => , - rows: (props) => , - bytes: (props) => , - bookmark: (props) => , - pencil: (props) => , - trash: (props) => , - check: (props) => , - upload: (props) => , - logout: (props) => , - spinner: ({ size = 13, ...props } = {}) => , - github: (props) => , -}; - -// ─── HEADER ─────────────────────────────────────────────────────────── -function AppHeader({ accent, onShortcuts }) { - const USER = { name: 'Demo User', email: 'demo@antalya.altinity.cloud', initials: 'DM', role: 'Read-only · demo' }; - const [menuOpen, setMenuOpen] = React.useState(false); - const [confirmOut, setConfirmOut] = React.useState(false); - return ( -
- {/* Logo */} -
-
A
-
Altinity Play
-
- antalya.demo -
-
- -
- - {/* Connection status */} -
-
- ClickHouse 26.3.10 -
- - - - - - - - {/* User menu */} -
- - - {menuOpen && ( - <> -
setMenuOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 40 }} /> -
-
- {USER.initials} -
-
{USER.name}
-
{USER.email}
-
-
-
- {USER.role} -
-
- -
-
- - )} -
- - {confirmOut && ( -
setConfirmOut(false)} style={{ - position: 'fixed', inset: 0, zIndex: 120, - background: 'rgba(0,0,0,.5)', backdropFilter: 'blur(4px)', - display: 'flex', alignItems: 'center', justifyContent: 'center', - }}> -
e.stopPropagation()} style={{ - width: 340, background: 'var(--bg-modal)', borderRadius: 11, - border: '1px solid var(--border)', boxShadow: '0 20px 60px rgba(0,0,0,.45)', - padding: '20px 22px', - }}> -
Log out?
-
- You'll be signed out of {USER.email}. Unsaved query tabs stay in this browser; saved queries are kept. -
-
- - -
-
-
- )} -
- ); -} - -// ─── SCHEMA TREE ────────────────────────────────────────────────────── -function SchemaTree({ accent, onInsertColumn }) { - const [tree, setTree] = React.useState(SCHEMA); - const [expandedTables, setExpandedTables] = React.useState(new Set(['ontime'])); - const [filter, setFilter] = React.useState(''); - - const toggleDb = (name) => { - setTree(t => t.map(db => db.name === name ? { ...db, expanded: !db.expanded } : db)); - }; - const toggleTable = (name) => { - setExpandedTables(s => { - const n = new Set(s); - if (n.has(name)) n.delete(name); else n.add(name); - return n; - }); - }; - - const matches = (s) => !filter || s.toLowerCase().includes(filter.toLowerCase()); - - return ( -
-
-
- - - - setFilter(e.target.value)} - placeholder="Search tables, columns…" - style={{ - width: '100%', - height: 26, - padding: '0 8px 0 26px', - background: 'var(--bg-input)', - border: '1px solid var(--border)', - borderRadius: 5, - color: 'var(--fg)', - fontSize: 11.5, - outline: 'none', - fontFamily: 'inherit', - }} - /> -
-
-
- {tree.map(db => ( -
- } - chevron={db.expanded ? : } - onClick={() => toggleDb(db.name)} - label={db.name} - meta={`${db.children.length}`} - bold - /> - {db.expanded && db.children.map(tb => { - const tkey = `${db.name}.${tb.name}`; - const open = expandedTables.has(tb.name); - const tableMatch = matches(tb.name); - const cols = tb.columns || []; - const visibleCols = cols.filter(c => matches(c.name)); - if (filter && !tableMatch && visibleCols.length === 0) return null; - return ( -
- } - chevron={cols.length ? (open ? : ) : null} - onClick={() => cols.length && toggleTable(tb.name)} - label={tb.name} - meta={tb.rows} - highlight={filter && tableMatch} - /> - {(open || (filter && visibleCols.length > 0)) && visibleCols.map(c => ( - } - label={c.name} - meta={c.type} - mono - onClick={() => onInsertColumn?.(c.name)} - highlight={filter && matches(c.name)} - small - /> - ))} -
- ); - })} -
- ))} -
-
- ); -} - -function TreeRow({ indent, icon, chevron, label, meta, bold, mono, highlight, onClick, small }) { - const [hover, setHover] = React.useState(false); - return ( -
setHover(true)} - onMouseLeave={() => setHover(false)} - style={{ - display: 'flex', - alignItems: 'center', - gap: 6, - height: small ? 22 : 24, - padding: `0 10px 0 ${10 + indent * 14}px`, - cursor: 'pointer', - background: hover ? 'var(--bg-hover)' : (highlight ? 'var(--bg-highlight)' : 'transparent'), - fontSize: small ? 11 : 12, - color: bold ? 'var(--fg)' : 'var(--fg-mute)', - fontWeight: bold ? 600 : 400, - fontFamily: mono ? 'var(--mono)' : 'inherit', - userSelect: 'none', - }} - > - - {chevron} - - {icon} - {label} - {meta && {meta}} -
- ); -} - -// ─── SAVED QUERIES + HISTORY ────────────────────────────────────────── -function SavedHistoryPanel({ accent, onLoadQuery, savedQueries, onRename, onDelete, onToggleStar, onImport }) { - const [tab, setTab] = React.useState('saved'); - const [toast, setToast] = React.useState(null); - const fileRef = React.useRef(null); - const list = savedQueries || SAVED_QUERIES; - - const flash = (msg) => { setToast(msg); setTimeout(() => setToast(null), 3200); }; - - const exportJson = () => { - const envelope = { - format: 'altinity-sql-browser/saved-queries', - version: 1, - exportedAt: new Date().toISOString(), - queries: list.map(q => ({ - id: q.id, name: q.name, sql: q.sql, starred: !!q.starred, - createdAt: q.createdAt || null, updatedAt: q.updatedAt || null, - })), - }; - const blob = new Blob([JSON.stringify(envelope, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `sql-browser-queries-${new Date().toISOString().slice(0, 10)}.json`; - document.body.appendChild(a); a.click(); a.remove(); - URL.revokeObjectURL(url); - flash(`Exported ${list.length} ${list.length === 1 ? 'query' : 'queries'}`); - }; - - const importJson = (e) => { - const file = e.target.files?.[0]; - e.target.value = ''; // allow re-importing same file - if (!file) return; - const reader = new FileReader(); - reader.onload = () => { - try { - const data = JSON.parse(reader.result); - if (data?.format !== 'altinity-sql-browser/saved-queries' || !Array.isArray(data.queries)) { - flash('✕ Not a valid saved-queries file'); return; - } - if (typeof data.version === 'number' && data.version > 1) { - flash('✕ File is from a newer version'); return; - } - const clean = data.queries - .filter(q => q && typeof q.sql === 'string' && typeof q.name === 'string') - .slice(0, 1000); - if (!clean.length) { flash('✕ No valid queries in file'); return; } - const summary = onImport?.(clean); - if (summary) flash(`Added ${summary.added} · updated ${summary.updated} · skipped ${summary.skipped}`); - } catch { - flash('✕ Could not parse JSON'); - } - }; - reader.readAsText(file); - }; - - const ioBtn = { - flex: 1, height: 24, border: '1px solid var(--border)', borderRadius: 5, - background: 'transparent', color: 'var(--fg-mute)', fontSize: 11, - fontFamily: 'inherit', cursor: 'pointer', display: 'flex', - alignItems: 'center', justifyContent: 'center', gap: 5, - }; - - return ( -
-
- {['saved', 'history'].map(t => ( - - ))} -
- {tab === 'saved' && ( - - )} -
- {tab === 'saved' && list.length === 0 && ( -
- No saved queries yet.
Write a query and hit Save, or Import a file. -
- )} - {tab === 'saved' && list.map(q => ( - onLoadQuery(q)} - onRename={onRename} onDelete={onDelete} onToggleStar={onToggleStar} /> - ))} - {tab === 'history' && HISTORY.map(h => ( - onLoadQuery({ name: 'From history', sql: h.sql })} /> - ))} -
- {tab === 'saved' && toast && ( -
{toast}
- )} - {tab === 'saved' && ( -
- - -
- )} -
- ); -} - -function SavedItem({ q, accent, onLoad, onRename, onDelete, onToggleStar }) { - const [hover, setHover] = React.useState(false); - const [editing, setEditing] = React.useState(false); - const [name, setName] = React.useState(q.name); - const inputRef = React.useRef(null); - React.useEffect(() => { setName(q.name); }, [q.name]); - - const startEdit = (e) => { - e.stopPropagation(); - setEditing(true); - requestAnimationFrame(() => { inputRef.current?.focus(); inputRef.current?.select(); }); - }; - const commit = () => { - setEditing(false); - const n = name.trim(); - if (n && n !== q.name) onRename?.(q.id, n); else setName(q.name); - }; - - const actionBtn = { - width: 20, height: 20, borderRadius: 4, border: 'none', padding: 0, - background: 'transparent', color: 'var(--fg-faint)', cursor: 'pointer', - display: 'flex', alignItems: 'center', justifyContent: 'center', - }; - - return ( -
!editing && onLoad()} - onMouseEnter={() => setHover(true)} - onMouseLeave={() => setHover(false)} - style={{ - padding: '8px 10px', - cursor: editing ? 'default' : 'pointer', - background: hover ? 'var(--bg-hover)' : 'transparent', - borderBottom: '1px solid var(--border-faint)', - position: 'relative', - }} - > -
- { e.stopPropagation(); onToggleStar?.(q.id); }} - style={{ color: q.starred ? accent : 'var(--fg-faint)', display: 'flex', cursor: 'pointer', flexShrink: 0 }} - title={q.starred ? 'Unstar' : 'Star'} - > - {Icon.star(q.starred)} - - {editing ? ( - setName(e.target.value)} - onClick={(e) => e.stopPropagation()} - onBlur={commit} - onKeyDown={(e) => { - if (e.key === 'Enter') { e.preventDefault(); commit(); } - if (e.key === 'Escape') { setName(q.name); setEditing(false); } - }} - style={{ - flex: 1, minWidth: 0, height: 20, padding: '0 5px', - background: 'var(--bg-input)', border: `1px solid ${accent}`, - borderRadius: 4, color: 'var(--fg)', fontSize: 12, fontWeight: 500, - outline: 'none', fontFamily: 'inherit', - }} - /> - ) : ( - - {q.name} - - )} - {hover && !editing && ( -
- - -
- )} -
-
- {q.sql.split('\n')[0]} -
-
- ); -} - -function HistoryItem({ h, accent, onLoad }) { - const [hover, setHover] = React.useState(false); - return ( -
setHover(true)} - onMouseLeave={() => setHover(false)} - style={{ - padding: '8px 10px', - cursor: 'pointer', - background: hover ? 'var(--bg-hover)' : 'transparent', - borderBottom: '1px solid var(--border-faint)', - }} - > -
- {h.sql} -
-
- {h.when} - {h.rows} rows - {h.ms} ms -
-
- ); -} - -// ─── QUERY TABS ─────────────────────────────────────────────────────── -function QueryTabs({ tabs, activeId, onSelect, onClose, onNew, accent }) { - return ( -
-
- {tabs.map(t => { - const active = t.id === activeId; - return ( -
onSelect(t.id)} - style={{ - display: 'flex', - alignItems: 'center', - gap: 8, - padding: '0 8px 0 12px', - height: '100%', - background: active ? 'var(--bg-editor)' : 'transparent', - borderRight: '1px solid var(--border)', - cursor: 'pointer', - fontSize: 11.5, - color: active ? 'var(--fg)' : 'var(--fg-mute)', - fontWeight: active ? 500 : 400, - position: 'relative', - whiteSpace: 'nowrap', - minWidth: 100, - }} - > - {active &&
} - {t.name} - {t.dirty && } - {tabs.length > 1 && ( - - )} -
- ); - })} -
- -
- ); -} - -// ─── EDITOR TOOLBAR ─────────────────────────────────────────────────── -function EditorToolbar({ accent, onRun, running, onFormat, onShare, onSave, currentName, isSaved, saveSignal }) { - const [saveOpen, setSaveOpen] = React.useState(false); - const [name, setName] = React.useState(currentName || ''); - const inputRef = React.useRef(null); - - const openSave = () => { - setName(currentName && currentName !== 'Untitled query' ? currentName : ''); - setSaveOpen(true); - requestAnimationFrame(() => inputRef.current?.focus()); - }; - const commit = () => { - const n = name.trim(); - if (n) { onSave(n); setSaveOpen(false); } - }; - - // ⌘S from the app raises saveSignal — open the popover (skip initial mount). - const firstSignal = React.useRef(true); - React.useEffect(() => { - if (firstSignal.current) { firstSignal.current = false; return; } - openSave(); - }, [saveSignal]); - - return ( -
- - - - - {/* Save + popover */} -
- - {saveOpen && ( - <> -
setSaveOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 30 }} /> -
-
Save query as
- setName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { e.preventDefault(); commit(); } - if (e.key === 'Escape') setSaveOpen(false); - }} - placeholder="e.g. Worst-delay carriers" - style={{ - width: '100%', height: 30, padding: '0 9px', boxSizing: 'border-box', - background: 'var(--bg-input)', border: '1px solid var(--border)', - borderRadius: 6, color: 'var(--fg)', fontSize: 12, outline: 'none', - fontFamily: 'inherit', - }} - /> -
- - -
-
- - )} -
- -
- - - - -
- ); -} - -// ─── RESULTS ────────────────────────────────────────────────────────── -function ResultsPane({ result, accent, density, running, progress, onCancel }) { - const [view, setView] = React.useState('table'); - const [sort, setSort] = React.useState({ col: null, dir: 'asc' }); - - const sorted = React.useMemo(() => { - if (!result || sort.col == null) return result?.rows; - const idx = sort.col; - const r = [...result.rows].sort((a, b) => { - const av = a[idx], bv = b[idx]; - if (typeof av === 'number') return sort.dir === 'asc' ? av - bv : bv - av; - return sort.dir === 'asc' - ? String(av).localeCompare(String(bv)) - : String(bv).localeCompare(String(av)); - }); - return r; - }, [result, sort]); - - return ( -
- {/* Results toolbar */} -
-
- {[ - { id: 'table', label: 'Table', icon: }, - { id: 'chart', label: 'Chart', icon: }, - { id: 'json', label: 'JSON', icon: }, - ].map(v => ( - - ))} -
- -
- - {running ? ( - <> - - - - ) : result ? ( - <> - {result.cancelled && ( - - Cancelled · partial - - )} - } value={`${result.meta.ms} ms`} /> - } value={`${result.meta.rows} rows`} /> - } value={result.meta.scanned} sub={`${result.meta.scannedRows} scanned`} /> - - - - ) : null} -
- -
- {/* Streaming progress strip atop the partial table */} - {running && ( -
- {progress && progress.total ? ( -
- ) : ( -
- )} -
- )} - {!result && !running && } - {!result && running && } - {result && view === 'table' && ( - - )} - {result && view === 'chart' && } - {result && view === 'json' && } -
-
- ); -} - -// Live ms/rows/bytes counters shown in the toolbar while a query streams. -// ms ticks smoothly off a local clock; rows/bytes come from `progress`. -function LiveRunStats({ progress, accent }) { - const [ms, setMs] = React.useState(0); - React.useEffect(() => { - const t0 = performance.now(); - const id = setInterval(() => setMs(performance.now() - t0), 50); - return () => clearInterval(id); - }, []); - const fmt = (n) => n >= 1e9 ? (n/1e9).toFixed(2)+'B' : n >= 1e6 ? (n/1e6).toFixed(1)+'M' : n >= 1e3 ? (n/1e3).toFixed(0)+'K' : String(n); - const liveStat = { - display: 'flex', alignItems: 'center', gap: 5, fontSize: 11, color: accent, - fontFamily: 'var(--mono)', padding: '0 8px', borderRight: '1px solid var(--border-faint)', - }; - return ( - <> -
{ms.toFixed(0)} ms
- {progress &&
{fmt(progress.read)} rows
} - {progress &&
{progress.bytes}
} - - ); -} - -function Stat({ icon, value, sub }) { - return ( -
- {icon} - {value} -
- ); -} - -function EmptyResults({ streaming }) { - return ( -
-
{streaming ? : }
- {streaming ?
Starting query…
: ( -
Press ⌘↵ to run query
- )} -
- ); -} - -function ResultsTable({ result, sorted, sort, setSort, accent, density }) { - const cellPad = density === 'compact' ? '4px 10px' : '7px 12px'; - return ( -
- - - - - {result.columns.map((c, i) => { - const isSort = sort.col === i; - return ( - - ); - })} - - - - {sorted.map((row, ri) => ( - - - {row.map((v, ci) => { - const col = result.columns[ci]; - const empty = v === null || v === undefined || v === ''; - return ( - - ); - })} - - ))} - -
# setSort({ col: i, dir: isSort && sort.dir === 'asc' ? 'desc' : 'asc' })} - style={{ - ...thStyle, - cursor: 'pointer', - padding: cellPad, - minWidth: 140, - }} - > -
- {c.name} - - {isSort && - {sort.dir === 'asc' ? : } - } -
-
- {ri + 1} - - {empty ? ( - - ) : ci === 0 && CARRIER_NAMES[v] ? ( - {v} - {CARRIER_NAMES[v]} - ) : ( - typeof v === 'number' ? v.toFixed(2) : String(v) - )} -
-
- ); -} - -const thStyle = { - position: 'sticky', top: 0, - background: 'var(--bg-th)', - borderBottom: '1px solid var(--border)', - borderRight: '1px solid var(--border-faint)', - textAlign: 'left', - fontWeight: 500, - fontSize: 11, - color: 'var(--fg-mute)', - whiteSpace: 'nowrap', - userSelect: 'none', -}; -const tdStyle = { - borderBottom: '1px solid var(--border-faint)', - borderRight: '1px solid var(--border-faint)', - whiteSpace: 'nowrap', -}; - -// ─── CHART HELPERS ──────────────────────────────────────────────────── -const CHART_NUM = /^(U?Int|Float|Decimal)/; -const CHART_TIME = /^(Date|DateTime)/; -const CHART_ORDINAL = /^(year|quarter|month|week|day|hour|dayofweek|minute)/i; -const chartStrip = (t) => { - let p = t; - let m; - while ((m = /^(Nullable|LowCardinality)\((.*)\)$/.exec(p))) p = m[2]; - return p; -}; -function chartRole(col) { - const t = chartStrip(col.type); - if (CHART_TIME.test(t)) return 'time'; - if (CHART_NUM.test(t)) return CHART_ORDINAL.test(col.name) ? 'ordinal' : 'measure'; - return 'category'; -} -// Good-enough default — the config bar lets the user override the 10% it -// gets wrong, so this stays a ~10-line heuristic, not a rule engine. -function autoChart(columns) { - const roles = columns.map((c, i) => ({ i, role: chartRole(c) })); - const measures = roles.filter((r) => r.role === 'measure').map((r) => r.i); - const x = (roles.find((r) => r.role === 'time') - || roles.find((r) => r.role === 'ordinal') - || roles.find((r) => r.role === 'category') - || roles[0]); - if (!measures.length || !x) return null; - const type = x.role === 'time' ? 'line' : x.role === 'category' ? 'hbar' : 'bar'; - return { type, x: x.i, y: measures, series: null }; -} -const CHART_PALETTE = (accent) => [accent, '#22C55E', '#E0B341', '#EC4899', '#14B8A6', '#A78BFA', '#F97316']; - -function useSize() { - const ref = React.useRef(null); - const [size, setSize] = React.useState({ w: 600, h: 300 }); - React.useEffect(() => { - if (!ref.current || typeof ResizeObserver === 'undefined') return; - const ro = new ResizeObserver((es) => { - const r = es[0].contentRect; - setSize({ w: Math.max(120, r.width), h: Math.max(120, r.height) }); - }); - ro.observe(ref.current); - return () => ro.disconnect(); - }, []); - return [ref, size]; -} - -function ChartSelect({ label, value, options, onChange, multi }) { - return ( - - ); -} - -function ResultsChart({ result, sorted, accent }) { - const cols = result.columns; - const auto = React.useMemo(() => autoChart(cols), [cols]); - const [cfg, setCfg] = React.useState(auto); - // Re-derive defaults when the result schema changes (different query). - const schemaKey = cols.map((c) => c.name + c.type).join('|'); - React.useEffect(() => { setCfg(autoChart(cols)); }, [schemaKey]); - const [chartRef, size] = useSize(); - - if (!cfg) { - return ( -
- -
These results aren't chartable.
Add a numeric column to plot them.
-
- ); - } - - const set = (patch) => setCfg((c) => ({ ...c, ...patch })); - const numericIdx = cols.map((c, i) => ({ c, i })).filter(({ c }) => chartRole(c) === 'measure' || chartRole(c) === 'ordinal').map(({ i }) => i); - const catIdx = cols.map((c, i) => ({ c, i })).filter(({ c }) => chartRole(c) !== 'measure').map(({ i }) => i); - const colOpts = cols.map((c, i) => ({ value: String(i), label: c.name })); - const yOpts = numericIdx.map((i) => ({ value: String(i), label: cols[i].name })); - const seriesOpts = [{ value: '', label: 'None' }, ...catIdx.filter((i) => i !== cfg.x).map((i) => ({ value: String(i), label: cols[i].name }))]; - - const types = [ - { value: 'hbar', label: 'Bar' }, - { value: 'bar', label: 'Column' }, - { value: 'line', label: 'Line' }, - { value: 'area', label: 'Area' }, - { value: 'pie', label: 'Pie' }, - ]; - - return ( -
- {/* Config bar */} -
- set({ type: v })} /> - set({ x: +v })} /> - set({ y: [+v] })} /> - {cfg.type !== 'pie' && yOpts.length > 1 && ( - - )} - {cfg.type !== 'pie' && seriesOpts.length > 1 && ( - set({ series: v === '' ? null : +v })} /> - )} -
- {/* Plot */} -
- -
-
- ); -} - -function ChartCanvas({ result, sorted, cfg, accent, w, h }) { - const cols = result.columns; - const palette = CHART_PALETTE(accent); - const fmtNum = (n) => typeof n !== 'number' ? n : Math.abs(n) >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : Math.abs(n) >= 1e3 ? (n / 1e3).toLocaleString() : (Number.isInteger(n) ? n : n.toFixed(2)); - const xLabel = (v) => { - const s = String(v); - return /^\d{4}-\d{2}-\d{2}/.test(s) ? s.slice(0, 7) : (CARRIER_NAMES[v] ? v : s); - }; - - // Build series: either group-by a category column, or one series per Y measure. - let series; // [{name, color, points:[{x, y}]}] - const xs = sorted.map((r) => r[cfg.x]); - if (cfg.series != null) { - const yi = cfg.y[0]; - const groups = {}; - const order = []; - sorted.forEach((r) => { - const k = String(r[cfg.series]); - if (!(k in groups)) { groups[k] = {}; order.push(k); } - groups[k][String(r[cfg.x])] = r[yi]; - }); - const xCats = [...new Set(xs.map(String))]; - series = order.map((k, i) => ({ name: k, color: palette[i % palette.length], - points: xCats.map((xc) => ({ x: xc, y: groups[k][xc] ?? 0 })) })); - } else { - series = cfg.y.map((yi, i) => ({ name: cols[yi].name, color: palette[i % palette.length], - points: sorted.map((r) => ({ x: r[cfg.x], y: r[yi] })) })); - } - - const PAD = { l: 52, r: 16, t: 16, b: 38 }; - - // Horizontal bars — best for ranked categorical data (the original design). - // Rendered as HTML rows so labels read naturally; supports grouped series. - if (cfg.type === 'hbar') { - const hmax = Math.max(0, ...series.flatMap((s) => s.points.map((p) => p.y))) || 1; - const cats = series[0]?.points.map((p) => p.x) ?? []; - const single = series.length === 1; - return ( -
- {!single && ( -
- {series.map((s, i) => ( - - {s.name} - - ))} -
- )} -
- {cats.map((cat, ri) => ( -
-
- {xLabel(cat)} - {CARRIER_NAMES[cat] && {CARRIER_NAMES[cat]}} -
-
- {series.map((s, si) => { - const val = s.points[ri]?.y ?? 0; - const pct = Math.max(0, (val / hmax) * 100); - return ( -
-
-
-
-
{fmtNum(val)}
-
- ); - })} -
-
- ))} -
-
- ); - } - - const iw = Math.max(10, w - PAD.l - PAD.r); - const ih = Math.max(10, h - PAD.t - PAD.b); - const allY = series.flatMap((s) => s.points.map((p) => p.y)); - const maxY = Math.max(0, ...allY); - const minY = Math.min(0, ...allY); - const yToPx = (v) => PAD.t + ih - ((v - minY) / (maxY - minY || 1)) * ih; - const cats = series[0]?.points.map((p) => p.x) ?? []; - const n = cats.length; - - // Y gridlines (4) - const ticks = 4; - const gridY = Array.from({ length: ticks + 1 }, (_, i) => minY + (i / ticks) * (maxY - minY)); - - const axisColor = 'var(--border)'; - const labelColor = 'var(--fg-faint)'; - const fontMono = 'var(--mono)'; - - if (cfg.type === 'pie') { - const pts = series[0]?.points ?? []; - const total = pts.reduce((a, p) => a + Math.max(0, p.y), 0) || 1; - const cx = w / 2, cy = h / 2, rad = Math.max(20, Math.min(w, h) / 2 - 60); - let a0 = -Math.PI / 2; - const arcs = pts.map((p, i) => { - const frac = Math.max(0, p.y) / total; - const a1 = a0 + frac * Math.PI * 2; - const large = a1 - a0 > Math.PI ? 1 : 0; - const x0 = cx + rad * Math.cos(a0), y0 = cy + rad * Math.sin(a0); - const x1 = cx + rad * Math.cos(a1), y1 = cy + rad * Math.sin(a1); - const d = `M${cx},${cy} L${x0},${y0} A${rad},${rad} 0 ${large} 1 ${x1},${y1} Z`; - a0 = a1; - return { d, color: palette[i % palette.length], label: xLabel(p.x), pct: (frac * 100).toFixed(0) }; - }); - return ( - - {arcs.map((a, i) => )} - {/* legend */} - {arcs.map((a, i) => ( - - - {a.label} · {a.pct}% - - ))} - - ); - } - - return ( - - {/* gridlines + y labels */} - {gridY.map((gv, i) => ( - - - {fmtNum(gv)} - - ))} - {/* x labels */} - {cats.map((c, i) => { - const step = iw / n; - const cxp = PAD.l + step * (i + 0.5); - if (n > 14 && i % Math.ceil(n / 12) !== 0) return null; - return {xLabel(c)}; - })} - - {/* bars */} - {cfg.type === 'bar' && series.map((s, si) => { - const step = iw / n; - const bw = (step * 0.7) / series.length; - return s.points.map((p, i) => { - const x = PAD.l + step * (i + 0.5) - (bw * series.length) / 2 + si * bw; - const y = yToPx(p.y), y0 = yToPx(0); - return - {xLabel(p.x)}: {fmtNum(p.y)} - ; - }); - })} - - {/* line / area */} - {(cfg.type === 'line' || cfg.type === 'area') && series.map((s, si) => { - const step = iw / n; - const px = (i) => PAD.l + step * (i + 0.5); - const path = s.points.map((p, i) => `${i === 0 ? 'M' : 'L'}${px(i)},${yToPx(p.y)}`).join(' '); - const areaPath = `${path} L${px(s.points.length - 1)},${yToPx(0)} L${px(0)},${yToPx(0)} Z`; - return ( - - {cfg.type === 'area' && } - - {s.points.map((p, i) => {xLabel(p.x)}: {fmtNum(p.y)})} - - ); - })} - - {/* legend (multi-series) */} - {series.length > 1 && series.map((s, i) => ( - - - {s.name} - - ))} - - ); -} - -function ResultsJson({ result, sorted }) { - const json = sorted.map(row => { - const obj = {}; - result.columns.forEach((c, i) => obj[c.name] = row[i]); - return obj; - }); - return ( -
-      {JSON.stringify(json, null, 2)}
-    
- ); -} - -// ─── SHORTCUTS DRAWER ───────────────────────────────────────────────── -function ShortcutsModal({ open, onClose }) { - if (!open) return null; - const groups = [ - { title: 'Editor', items: [ - ['Run query', '⌘ ↵'], - ['Save query', '⌘ S'], - ['Format SQL', '⌘ ⇧ F'], - ['New tab', '⌘ T'], - ['Close tab', '⌘ W'], - ['Comment line', '⌘ /'], - ]}, - { title: 'Navigation', items: [ - ['Toggle sidebar', '⌘ B'], - ['Focus editor', '⌘ E'], - ['Search schema', '⌘ K'], - ['Show shortcuts', '?'], - ]}, - { title: 'Results', items: [ - ['Copy as TSV', '⌘ ⇧ C'], - ['Export CSV', '⌘ ⇧ E'], - ['Switch to chart', '⌘ 2'], - ]}, - ]; - return ( -
-
e.stopPropagation()} style={{ - width: 480, background: 'var(--bg-modal)', borderRadius: 10, - border: '1px solid var(--border)', boxShadow: '0 20px 60px rgba(0,0,0,.4)', - padding: '18px 22px', - }}> -
- Keyboard shortcuts -
- {groups.map(g => ( -
-
{g.title}
- {g.items.map(([label, key]) => ( -
- {label} - {key} -
- ))} -
- ))} -
-
- ); -} - -Object.assign(window, { - AppHeader, SchemaTree, SavedHistoryPanel, QueryTabs, EditorToolbar, ResultsPane, - ShortcutsModal, Icon, -}); diff --git a/design/data.jsx b/design/data.jsx deleted file mode 100644 index 1bd1317..0000000 --- a/design/data.jsx +++ /dev/null @@ -1,188 +0,0 @@ -// data.jsx — sample airline ontime data, schema, queries - -const SCHEMA = [ - { - name: 'airline', - type: 'database', - expanded: true, - children: [ - { name: 'ontime', type: 'table', rows: '198.3M', size: '94.1 GB', - columns: [ - { name: 'Year', type: 'UInt16' }, - { name: 'Quarter', type: 'UInt8' }, - { name: 'Month', type: 'UInt8' }, - { name: 'DayofMonth', type: 'UInt8' }, - { name: 'DayOfWeek', type: 'UInt8' }, - { name: 'FlightDate', type: 'Date' }, - { name: 'Reporting_Airline', type: 'LowCardinality(String)' }, - { name: 'Tail_Number', type: 'String' }, - { name: 'Flight_Number_Reporting_Airline', type: 'String' }, - { name: 'OriginAirportID', type: 'UInt32' }, - { name: 'Origin', type: 'LowCardinality(String)' }, - { name: 'OriginCityName', type: 'String' }, - { name: 'OriginState', type: 'LowCardinality(String)' }, - { name: 'DestAirportID', type: 'UInt32' }, - { name: 'Dest', type: 'LowCardinality(String)' }, - { name: 'DestCityName', type: 'String' }, - { name: 'DestState', type: 'LowCardinality(String)' }, - { name: 'CRSDepTime', type: 'UInt16' }, - { name: 'DepTime', type: 'Nullable(UInt16)' }, - { name: 'DepDelay', type: 'Nullable(Int16)' }, - { name: 'DepDelayMinutes', type: 'Nullable(UInt16)' }, - { name: 'TaxiOut', type: 'Nullable(UInt16)' }, - { name: 'WheelsOff', type: 'Nullable(UInt16)' }, - { name: 'WheelsOn', type: 'Nullable(UInt16)' }, - { name: 'TaxiIn', type: 'Nullable(UInt16)' }, - { name: 'CRSArrTime', type: 'UInt16' }, - { name: 'ArrTime', type: 'Nullable(UInt16)' }, - { name: 'ArrDelay', type: 'Nullable(Int16)' }, - { name: 'ArrDelayMinutes', type: 'Nullable(UInt16)' }, - { name: 'Cancelled', type: 'UInt8' }, - { name: 'CancellationCode', type: 'LowCardinality(String)' }, - { name: 'Diverted', type: 'UInt8' }, - { name: 'AirTime', type: 'Nullable(UInt16)' }, - { name: 'Flights', type: 'UInt8' }, - { name: 'Distance', type: 'UInt16' }, - { name: 'CarrierDelay', type: 'Nullable(UInt16)' }, - { name: 'WeatherDelay', type: 'Nullable(UInt16)' }, - { name: 'NASDelay', type: 'Nullable(UInt16)' }, - { name: 'SecurityDelay', type: 'Nullable(UInt16)' }, - { name: 'LateAircraftDelay', type: 'Nullable(UInt16)' }, - ]}, - { name: 'airports', type: 'table', rows: '6.4K', size: '892 KB', - columns: [ - { name: 'AirportID', type: 'UInt32' }, - { name: 'Code', type: 'LowCardinality(String)' }, - { name: 'Name', type: 'String' }, - { name: 'City', type: 'String' }, - { name: 'State', type: 'LowCardinality(String)' }, - { name: 'Country', type: 'LowCardinality(String)' }, - { name: 'Lat', type: 'Float64' }, - { name: 'Lon', type: 'Float64' }, - ]}, - { name: 'carriers', type: 'table', rows: '1.5K', size: '124 KB', - columns: [ - { name: 'Code', type: 'LowCardinality(String)' }, - { name: 'Description', type: 'String' }, - ]}, - ], - }, - { - name: 'system', - type: 'database', - expanded: false, - children: [ - { name: 'tables', type: 'table', rows: '142', size: '—' }, - { name: 'columns', type: 'table', rows: '1.8K', size: '—' }, - { name: 'parts', type: 'table', rows: '4.2K', size: '—' }, - { name: 'query_log', type: 'table', rows: '892K', size: '218 MB' }, - { name: 'metrics', type: 'table', rows: '320', size: '—' }, - ], - }, - { - name: 'default', - type: 'database', - expanded: false, - children: [], - }, -]; - -const SAVED_QUERIES = [ - { id: 'q1', name: 'Worst-delay carriers (2023)', starred: true, - sql: `SELECT Reporting_Airline, avg(DepDelayMinutes) AS avg_delay\nFROM airline.ontime\nWHERE Year = 2023 AND Cancelled = 0\nGROUP BY Reporting_Airline\nORDER BY avg_delay DESC\nLIMIT 15` }, - { id: 'q2', name: 'Busiest origin airports', starred: true, - sql: `SELECT Origin, count() AS flights\nFROM airline.ontime\nWHERE Year = 2023\nGROUP BY Origin\nORDER BY flights DESC\nLIMIT 20` }, - { id: 'q3', name: 'Monthly cancellations 2019–2023', starred: false, - sql: `SELECT toStartOfMonth(FlightDate) AS month, sum(Cancelled) AS cancellations\nFROM airline.ontime\nWHERE Year BETWEEN 2019 AND 2023\nGROUP BY month\nORDER BY month` }, - { id: 'q4', name: 'On-time % by day of week', starred: false, - sql: `SELECT DayOfWeek, round(avg(DepDelayMinutes < 15) * 100, 2) AS ontime_pct\nFROM airline.ontime\nWHERE Year = 2023 AND Cancelled = 0\nGROUP BY DayOfWeek\nORDER BY DayOfWeek` }, -]; - -const HISTORY = [ - { id: 'h1', sql: 'SELECT count() FROM airline.ontime', when: '2 min ago', rows: 1, ms: 12 }, - { id: 'h2', sql: 'SELECT Reporting_Airline, avg(DepDelayMinutes) ...', when: '14 min ago', rows: 15, ms: 218 }, - { id: 'h3', sql: 'DESCRIBE TABLE airline.ontime', when: '32 min ago', rows: 39, ms: 4 }, - { id: 'h4', sql: 'SELECT Origin, count() FROM airline.ontime ...', when: '1 h ago', rows: 20, ms: 184 }, - { id: 'h5', sql: 'SHOW DATABASES', when: '2 h ago', rows: 3, ms: 2 }, - { id: 'h6', sql: 'SELECT * FROM airline.ontime LIMIT 100', when: 'Yesterday', rows: 100, ms: 38 }, -]; - -// Result for "worst-delay carriers" query -const RESULT_DELAYS = { - columns: [ - { name: 'Reporting_Airline', type: 'String' }, - { name: 'avg_delay', type: 'Float64' }, - ], - rows: [ - ['B6', 22.41], // JetBlue - ['F9', 19.83], // Frontier - ['NK', 18.92], // Spirit - ['G4', 17.20], // Allegiant - ['UA', 14.55], // United - ['AA', 13.87], // American - ['WN', 13.04], // Southwest - ['MQ', 12.61], // Envoy - ['9E', 11.98], // Endeavor - ['YX', 11.42], // Republic - ['OO', 10.85], // SkyWest - ['DL', 10.21], // Delta - ['AS', 9.76], // Alaska - ['HA', 8.93], // Hawaiian - ['QX', 7.41], // Horizon - ], - meta: { rows: 15, ms: 218, scanned: '2.41 GB', scannedRows: '64.1M' }, -}; - -const CARRIER_NAMES = { - B6: 'JetBlue', F9: 'Frontier', NK: 'Spirit', G4: 'Allegiant', - UA: 'United', AA: 'American', WN: 'Southwest', MQ: 'Envoy', - '9E': 'Endeavor', YX: 'Republic', OO: 'SkyWest', DL: 'Delta', - AS: 'Alaska', HA: 'Hawaiian', QX: 'Horizon', -}; - -// Temporal result — monthly, two measures (drives Line/Area + multi-series demo) -const RESULT_MONTHLY = { - columns: [ - { name: 'month', type: 'Date' }, - { name: 'cancellations', type: 'UInt32' }, - { name: 'diversions', type: 'UInt32' }, - ], - rows: [ - ['2023-01-01', 18420, 3210], - ['2023-02-01', 15880, 2870], - ['2023-03-01', 14110, 2640], - ['2023-04-01', 12950, 2510], - ['2023-05-01', 13670, 2730], - ['2023-06-01', 21030, 3980], - ['2023-07-01', 23510, 4320], - ['2023-08-01', 19980, 3760], - ['2023-09-01', 11240, 2190], - ['2023-10-01', 10870, 2050], - ['2023-11-01', 13320, 2480], - ['2023-12-01', 22760, 4110], - ], - meta: { rows: 12, ms: 96, scanned: '1.18 GB', scannedRows: '31.2M' }, -}; - -// Ordinal-numeric X (DayOfWeek 1–7) + one measure -const RESULT_DOW = { - columns: [ - { name: 'DayOfWeek', type: 'UInt8' }, - { name: 'ontime_pct', type: 'Float64' }, - ], - rows: [ - [1, 79.4], [2, 82.1], [3, 83.6], [4, 80.2], [5, 76.8], [6, 85.3], [7, 81.0], - ], - meta: { rows: 7, ms: 142, scanned: '2.05 GB', scannedRows: '58.7M' }, -}; - -// Pick a result by inspecting the SQL — lets the demo show different chart -// shapes (bar vs line) depending on what the query looks like. -function pickResult(sql) { - const s = (sql || '').toLowerCase(); - if (/month|tostartof|flightdate|group by .*\bdate\b/.test(s)) return RESULT_MONTHLY; - if (/dayofweek/.test(s)) return RESULT_DOW; - return RESULT_DELAYS; -} - -Object.assign(window, { SCHEMA, SAVED_QUERIES, HISTORY, RESULT_DELAYS, RESULT_MONTHLY, RESULT_DOW, CARRIER_NAMES, pickResult }); diff --git a/design/editor-complete.jsx b/design/editor-complete.jsx deleted file mode 100644 index 50f497a..0000000 --- a/design/editor-complete.jsx +++ /dev/null @@ -1,215 +0,0 @@ -// editor-complete.jsx — autocomplete (#26), signature help + hover docs (#27) -// All client-side off cached reference data — never runs SQL on the keystroke -// path. Popovers are positioned by the editor via caret coordinates. - -// ── completion context ────────────────────────────────────────────────────── -// What word is being typed at the caret, and is it qualified (after a dot)? -function completionContext(value, pos) { - let s = pos; - while (s > 0 && /[A-Za-z0-9_]/.test(value[s - 1])) s--; - const word = value.slice(s, pos); - const qualified = value[s - 1] === '.'; - let parent = null; - if (qualified) { - let p = s - 1; - while (p > 0 && /[A-Za-z0-9_]/.test(value[p - 1])) p--; - parent = value.slice(p, s - 1); - } - return { word, from: s, to: pos, qualified, parent }; -} - -const KIND_META = { - keyword: { glyph: 'K', color: '#C586C0', label: 'keyword' }, - fn: { glyph: 'ƒ', color: '#DCDCAA', label: 'function' }, - agg: { glyph: 'Σ', color: '#E0B341', label: 'aggregate' }, - cast: { glyph: '⇄', color: '#4FC1FF', label: 'cast' }, - table: { glyph: '▦', color: '#FF6B35', label: 'table' }, - column: { glyph: '▪', color: '#92E1D8', label: 'column' }, - db: { glyph: '◈', color: '#A0A0A8', label: 'database' }, -}; - -// Rank candidates: qualified → only that table's columns. Otherwise prefix -// matches first, then substring; columns/tables rank above keywords when the -// user has typed ≥1 char. Caps the list for a tight dropdown. -function rankCompletions(items, ctx) { - const w = ctx.word.toLowerCase(); - if (ctx.qualified) { - const cols = items.filter((it) => it.kind === 'column' && it.parent === ctx.parent); - return (w ? cols.filter((c) => c.label.toLowerCase().includes(w)) : cols).slice(0, 50); - } - if (!w) { - return items.filter((it) => it.kind === 'keyword' || it.kind === 'table').slice(0, 40); - } - const scored = []; - for (const it of items) { - const l = it.label.toLowerCase(); - const idx = l.indexOf(w); - if (idx === -1) continue; - let score = idx === 0 ? 0 : 100 + idx; // prefix beats substring - if (it.kind === 'column' || it.kind === 'table') score -= 10; // boost schema - if (it.kind === 'keyword') score += 5; - score += (l.length - w.length) * 0.1; // prefer closer length - scored.push({ it, score }); - } - scored.sort((a, b) => a.score - b.score || a.it.label.localeCompare(b.it.label)); - return scored.slice(0, 50).map((s) => s.it); -} - -function HiMatch({ text, q }) { - if (!q) return text; - const i = text.toLowerCase().indexOf(q.toLowerCase()); - if (i === -1) return text; - return ( - <>{text.slice(0, i)}{text.slice(i, i + q.length)}{text.slice(i + q.length)} - ); -} - -function AutocompleteDropdown({ items, active, query, coords, onPick, accent }) { - const listRef = React.useRef(null); - React.useEffect(() => { - const el = listRef.current?.children[active]; - if (el) el.scrollIntoView({ block: 'nearest' }); - }, [active]); - if (!items.length) return null; - - // position:fixed in a body portal so the editor's overflow:hidden can't clip - // it. Flip above the caret only when there's more room above; clamp to the - // viewport so a short editor pane can't push it off-screen. - const W = 350; - const { cx, cy, lhPx, vw, vh } = coords; - const spaceBelow = vh - (cy + lhPx); - const spaceAbove = cy; - const below = spaceBelow > 248 || spaceBelow >= spaceAbove; - const maxH = Math.max(120, Math.min(248, (below ? spaceBelow : spaceAbove) - 10)); - const left = Math.max(8, Math.min(cx, vw - W - 8)); - const pos = below - ? { top: Math.round(cy + lhPx + 2) } - : { bottom: Math.round(vh - cy + 2) }; - const cur = items[active]; - - return ReactDOM.createPortal( -
-
- {items.map((it, i) => { - const m = KIND_META[it.kind] || KIND_META.fn; - const on = i === active; - return ( -
{ e.preventDefault(); onPick(it); }} - style={{ - display: 'flex', alignItems: 'center', gap: 9, padding: '5px 8px', - borderRadius: 5, cursor: 'pointer', - background: on ? `color-mix(in oklab, ${accent} 20%, transparent)` : 'transparent', - }}> - {m.glyph} - - - - {it.detail} -
- ); - })} -
- {(cur.doc || cur.ret) && ( -
- {cur.detail && cur.kind !== 'keyword' && {cur.detail}{cur.ret ? ` → ${cur.ret}` : ''}} - {cur.doc && {cur.doc}} -
- )} -
, - document.body, - ); -} - -// ── signature help ─────────────────────────────────────────────────────────── -// Walk back from caret to find an unclosed "fnName(" and which arg index we're on. -function signatureContext(value, pos) { - let depth = 0, i = pos - 1, argIdx = 0; - while (i >= 0) { - const c = value[i]; - if (c === ')') depth++; - else if (c === '(') { - if (depth === 0) { - let e = i; - while (e > 0 && /[A-Za-z0-9_]/.test(value[e - 1])) e--; - const name = value.slice(e, i); - if (name) return { name, argIdx }; - return null; - } - depth--; - } else if (c === ',' && depth === 0) argIdx++; - else if ((c === ';' || c === '\n') && depth === 0) return null; - i--; - } - return null; -} - -function SignatureHelp({ sig, name, argIdx, ret, coords }) { - if (!sig) return null; - const { cx, cy, lhPx, vw, vh } = coords; - // Split args to bold the active one. - const open = sig.indexOf('('); - const inner = sig.slice(open + 1, sig.lastIndexOf(')')); - const args = inner.split(','); - // Prefer above the caret; drop below if there's no room up top. Clamp left. - const above = cy > 40; - const left = Math.max(8, Math.min(cx, vw - 320)); - const pos = above ? { bottom: Math.round(vh - cy + 4) } : { top: Math.round(cy + lhPx + 4) }; - return ReactDOM.createPortal( -
- {name}( - {args.map((a, i) => ( - - {a.trim()} - {i < args.length - 1 ? ', ' : ''} - - ))}) - {ret && → {ret}} -
, - document.body, - ); -} - -// ── hover card ──────────────────────────────────────────────────────────────── -function HoverCard({ title, sig, ret, doc, x, y }) { - return ( -
-
- {sig || title}{ret ? → {ret} : null} -
- {doc &&
{doc}
} -
- ); -} - -Object.assign(window, { - completionContext, rankCompletions, AutocompleteDropdown, - signatureContext, SignatureHelp, HoverCard, KIND_META, -}); diff --git a/design/editor-data.jsx b/design/editor-data.jsx deleted file mode 100644 index 0d8ed7b..0000000 --- a/design/editor-data.jsx +++ /dev/null @@ -1,102 +0,0 @@ -// editor-data.jsx — reference data for autocomplete, hover docs, signature help -// -// In production this is loaded ONCE per connection (the "keystroke rule" — -// never run SQL on the keystroke path) from ClickHouse system tables: -// • system.keywords → dynamic keyword list (feeds the tokenizer too) -// • system.functions → names + (where available) signatures -// • system.completions → context/belongs-aware completion candidates -// • system.documentation → hover docs / descriptions (Phase 2c, lazy) -// then cached in memory for the session. Here we hardcode a representative -// slice so the design is concrete and the UX is exercisable offline. - -// Keyword list (a superset of the tokenizer's built-in set). The tokenizer -// now accepts these dynamically: tokenize(sql, { keywords, funcs }). -const REF_KEYWORDS = [ - 'SELECT','FROM','WHERE','AND','OR','NOT','IN','BETWEEN','LIKE','ILIKE','IS','NULL', - 'GROUP BY','ORDER BY','HAVING','LIMIT','OFFSET','AS','ON','USING','JOIN','INNER', - 'LEFT','RIGHT','OUTER','FULL','CROSS','UNION','ALL','DISTINCT','CASE','WHEN', - 'THEN','ELSE','END','WITH','INSERT','INTO','VALUES','UPDATE','SET','DELETE', - 'CREATE','TABLE','VIEW','MATERIALIZED','INDEX','DROP','ALTER','SHOW','DESCRIBE', - 'EXPLAIN','USE','SETTINGS','FORMAT','PREWHERE','FINAL','SAMPLE','ARRAY JOIN', - 'TOP','ANTI','SEMI','ANY','ASOF','GLOBAL','INTERVAL','TTL','PARTITION BY', -]; - -// Function reference: name → { sig, ret, desc, kind } -// kind drives the icon/category in the autocomplete dropdown. -const REF_FUNCTIONS = { - count: { sig: 'count([x])', ret: 'UInt64', kind: 'agg', desc: 'Counts rows or non-NULL values of x.' }, - sum: { sig: 'sum(x)', ret: 'numeric', kind: 'agg', desc: 'Sum of values across the group.' }, - avg: { sig: 'avg(x)', ret: 'Float64', kind: 'agg', desc: 'Arithmetic mean across the group.' }, - min: { sig: 'min(x)', ret: 'same as x', kind: 'agg', desc: 'Minimum value across the group.' }, - max: { sig: 'max(x)', ret: 'same as x', kind: 'agg', desc: 'Maximum value across the group.' }, - uniq: { sig: 'uniq(x, …)', ret: 'UInt64', kind: 'agg', desc: 'Approximate number of distinct values (adaptive HLL).' }, - uniqExact: { sig: 'uniqExact(x)', ret: 'UInt64', kind: 'agg', desc: 'Exact number of distinct values. Uses more memory than uniq.' }, - quantile: { sig: 'quantile(level)(x)', ret: 'Float64', kind: 'agg', desc: 'Approximate quantile at level∈[0,1] over x (reservoir sampling).' }, - groupArray: { sig: 'groupArray([max])(x)', ret: 'Array', kind: 'agg', desc: 'Collects values of x into an array.' }, - any: { sig: 'any(x)', ret: 'same as x', kind: 'agg', desc: 'Returns the first value encountered in the group.' }, - round: { sig: 'round(x[, N])', ret: 'numeric', kind: 'fn', desc: 'Rounds x to N decimal places (banker’s rounding).' }, - floor: { sig: 'floor(x[, N])', ret: 'numeric', kind: 'fn', desc: 'Rounds x down toward negative infinity.' }, - ceil: { sig: 'ceil(x[, N])', ret: 'numeric', kind: 'fn', desc: 'Rounds x up toward positive infinity.' }, - abs: { sig: 'abs(x)', ret: 'numeric', kind: 'fn', desc: 'Absolute value of x.' }, - length: { sig: 'length(x)', ret: 'UInt64', kind: 'fn', desc: 'Number of bytes in a string, or elements in an array.' }, - lower: { sig: 'lower(s)', ret: 'String', kind: 'fn', desc: 'Lowercases an ASCII string.' }, - upper: { sig: 'upper(s)', ret: 'String', kind: 'fn', desc: 'Uppercases an ASCII string.' }, - concat: { sig: 'concat(s1, s2, …)', ret: 'String', kind: 'fn', desc: 'Concatenates the string arguments.' }, - substring: { sig: 'substring(s, off[, len])', ret: 'String', kind: 'fn', desc: 'Substring starting at 1-based offset.' }, - splitByChar: { sig: 'splitByChar(sep, s)', ret: 'Array(String)', kind: 'fn', desc: 'Splits s by a single-character separator.' }, - toString: { sig: 'toString(x)', ret: 'String', kind: 'cast', desc: 'Converts any value to its String representation.' }, - toDate: { sig: 'toDate(x)', ret: 'Date', kind: 'cast', desc: 'Converts a value or string to a Date.' }, - toDateTime: { sig: 'toDateTime(x)', ret: 'DateTime', kind: 'cast', desc: 'Converts a value or string to a DateTime.' }, - toUInt32: { sig: 'toUInt32(x)', ret: 'UInt32', kind: 'cast', desc: 'Casts x to UInt32 (throws on overflow).' }, - toFloat64: { sig: 'toFloat64(x)', ret: 'Float64', kind: 'cast', desc: 'Casts x to Float64.' }, - toStartOfMonth: { sig: 'toStartOfMonth(d)', ret: 'Date', kind: 'fn', desc: 'Rounds a date/datetime down to the first day of its month.' }, - toStartOfWeek: { sig: 'toStartOfWeek(d[, mode])', ret: 'Date', kind: 'fn', desc: 'Rounds a date down to the start of its week.' }, - toStartOfDay: { sig: 'toStartOfDay(d)', ret: 'DateTime', kind: 'fn', desc: 'Rounds a datetime down to 00:00:00 of its day.' }, - formatDateTime: { sig: 'formatDateTime(t, fmt)', ret: 'String', kind: 'fn', desc: 'Formats a datetime using a strftime-like pattern.' }, - now: { sig: 'now()', ret: 'DateTime', kind: 'fn', desc: 'Current server date and time.' }, - today: { sig: 'today()', ret: 'Date', kind: 'fn', desc: 'Current server date.' }, - if: { sig: 'if(cond, then, else)', ret: 'inferred', kind: 'fn', desc: 'Branchless ternary; returns then when cond is non-zero.' }, - multiIf: { sig: 'multiIf(c1, v1, …, else)', ret: 'inferred', kind: 'fn', desc: 'Chained conditionals — like CASE WHEN, as a function.' }, - coalesce: { sig: 'coalesce(x, …)', ret: 'inferred', kind: 'fn', desc: 'First non-NULL argument, or NULL if all are NULL.' }, - isNull: { sig: 'isNull(x)', ret: 'UInt8', kind: 'fn', desc: 'Returns 1 if x is NULL, else 0.' }, - greatest: { sig: 'greatest(a, b, …)', ret: 'inferred', kind: 'fn', desc: 'Largest of the arguments.' }, - least: { sig: 'least(a, b, …)', ret: 'inferred', kind: 'fn', desc: 'Smallest of the arguments.' }, - arrayJoin: { sig: 'arrayJoin(arr)', ret: 'rows', kind: 'fn', desc: 'Unfolds an array, emitting one row per element.' }, -}; - -// Short docs for a few keywords (hover docs, Phase 2c). -const REF_KEYWORD_DOCS = { - PREWHERE: 'ClickHouse-specific filter applied before reading other columns — an optimization over WHERE for selective predicates.', - FINAL: 'Forces merge of rows with the same key at read time (ReplacingMergeTree etc.). Expensive; avoid on hot paths.', - SAMPLE: 'Reads a deterministic fraction of data for approximate results. Requires a SAMPLE BY key on the table.', - 'ARRAY JOIN': 'Joins each row with the elements of one of its array columns, multiplying rows.', - LIMIT: 'Caps the number of returned rows. LIMIT n BY expr limits per group.', - SETTINGS: 'Per-query settings override, e.g. SETTINGS max_threads = 4.', -}; - -// Build the completion candidate list. In production this merges -// system.completions with the loaded schema; here we assemble from -// REF_KEYWORDS + REF_FUNCTIONS + the in-memory SCHEMA (databases, tables, -// and ONLY already-loaded columns — no on-demand column fetch). -function buildCompletions(schema) { - const items = []; - REF_KEYWORDS.forEach((k) => items.push({ label: k, kind: 'keyword', insert: k, detail: 'keyword' })); - Object.entries(REF_FUNCTIONS).forEach(([name, m]) => - items.push({ label: name, kind: m.kind === 'agg' ? 'agg' : m.kind === 'cast' ? 'cast' : 'fn', - insert: name + '(', detail: m.sig, doc: m.desc, ret: m.ret })); - (schema || []).forEach((db) => { - items.push({ label: db.name, kind: 'db', insert: db.name, detail: 'database' }); - (db.children || []).forEach((tb) => { - items.push({ label: tb.name, kind: 'table', insert: tb.name, detail: `table · ${tb.rows} rows` }); - // Only already-loaded columns (table.columns !== null) — matches the - // resolved decision in #25/#26. - (tb.columns || []).forEach((c) => - items.push({ label: c.name, kind: 'column', insert: c.name, detail: c.type, parent: tb.name })); - }); - }); - return items; -} - -Object.assign(window, { - REF_KEYWORDS, REF_FUNCTIONS, REF_KEYWORD_DOCS, buildCompletions, -}); diff --git a/design/editor-search.jsx b/design/editor-search.jsx deleted file mode 100644 index 5050889..0000000 --- a/design/editor-search.jsx +++ /dev/null @@ -1,143 +0,0 @@ -// editor-search.jsx — in-editor find/replace (#23) -// Pure match-finder + the floating panel UI. Highlights are drawn by the -// editor's transparent overlay
 (see sql-editor.jsx), per the resolved
-// design: a second color:transparent 
 carrying only mark spans, never
-// splitting the token render path.
-
-function findMatches(value, query, opts = {}) {
-  if (!query) return [];
-  const { caseSensitive = false, regex = false, wholeWord = false } = opts;
-  const matches = [];
-  try {
-    let re;
-    if (regex) {
-      re = new RegExp(query, caseSensitive ? 'g' : 'gi');
-    } else {
-      let pat = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
-      if (wholeWord) pat = `\\b${pat}\\b`;
-      re = new RegExp(pat, caseSensitive ? 'g' : 'gi');
-    }
-    let m;
-    let guard = 0;
-    while ((m = re.exec(value)) !== null) {
-      if (m.index === re.lastIndex) re.lastIndex++; // zero-width guard
-      matches.push({ start: m.index, end: m.index + m[0].length });
-      if (++guard > 10000) break;
-    }
-  } catch (e) {
-    return []; // invalid regex → no matches (panel shows the error state)
-  }
-  return matches;
-}
-
-function validRegex(query, regex) {
-  if (!regex || !query) return true;
-  try { new RegExp(query); return true; } catch { return false; }
-}
-
-function SearchPanel({
-  accent, query, setQuery, replace, setReplace, opts, setOpts,
-  matchCount, activeIndex, showReplace, setShowReplace,
-  onNext, onPrev, onReplace, onReplaceAll, onClose, inputRef,
-}) {
-  const badQuery = !validRegex(query, opts.regex);
-  const tog = (k) => setOpts({ ...opts, [k]: !opts[k] });
-
-  const toggleBtn = (active, label, title, onClick) => (
-    
-  );
-
-  const iconBtn = (children, title, onClick, disabled) => (
-    
-  );
-
-  const fieldWrap = { display: 'flex', alignItems: 'center', gap: 4 };
-  const field = {
-    width: 190, height: 26, padding: '0 8px', background: 'var(--bg-input)',
-    border: `1px solid ${badQuery ? '#ef4444' : 'var(--border)'}`, borderRadius: 6,
-    color: 'var(--fg)', fontSize: 12, fontFamily: 'var(--mono)', outline: 'none',
-  };
-
-  return (
-    
- {/* expand replace toggle */} - - -
- {/* find row */} -
- setQuery(e.target.value)} - placeholder="Find" style={field} spellCheck={false} - onKeyDown={(e) => { - if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? onPrev() : onNext(); } - if (e.key === 'Escape') { e.preventDefault(); onClose(); } - }} /> - - {badQuery ? 'bad re' : matchCount ? `${activeIndex + 1}/${matchCount}` : '0/0'} - - {iconBtn(, 'Previous (⇧⏎)', onPrev, !matchCount)} - {iconBtn(, 'Next (⏎)', onNext, !matchCount)} -
- {toggleBtn(opts.caseSensitive, 'Aa', 'Match case', () => tog('caseSensitive'))} - {toggleBtn(opts.wholeWord, 'W', 'Whole word', () => tog('wholeWord'))} - {toggleBtn(opts.regex, '.*', 'Regular expression', () => tog('regex'))} -
- {iconBtn(, 'Close (Esc)', onClose)} -
- - {/* replace row */} - {showReplace && ( -
- setReplace(e.target.value)} - placeholder="Replace" style={field} spellCheck={false} - onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); onReplace(); } if (e.key === 'Escape') onClose(); }} /> - - -
- )} -
-
- ); -} - -Object.assign(window, { findMatches, validRegex, SearchPanel }); diff --git a/design/sql-editor.jsx b/design/sql-editor.jsx deleted file mode 100644 index a795425..0000000 --- a/design/sql-editor.jsx +++ /dev/null @@ -1,514 +0,0 @@ -// sql-editor.jsx — syntax-highlighted SQL editor (textarea over
)
-// Enhancements (issues #23–#27), all built on the textarea surface:
-//   #23 find/replace  #24 bracket match+auto-close  #25 dynamic-keyword API
-//   #26 autocomplete   #27 signature help + hover docs
-// Reference data, search UI, and completion UI live in editor-data.jsx,
-// editor-search.jsx, editor-complete.jsx.
-
-// ── default token sets (tokenizer also accepts dynamic ones, see #25) ────────
-const SQL_KEYWORDS = new Set([
-  'SELECT','FROM','WHERE','AND','OR','NOT','IN','BETWEEN','LIKE','IS','NULL',
-  'GROUP','BY','ORDER','HAVING','LIMIT','OFFSET','AS','ON','JOIN','INNER',
-  'LEFT','RIGHT','OUTER','FULL','CROSS','UNION','ALL','DISTINCT','CASE','WHEN',
-  'THEN','ELSE','END','WITH','INSERT','INTO','VALUES','UPDATE','SET','DELETE',
-  'CREATE','TABLE','VIEW','INDEX','DROP','ALTER','SHOW','DESCRIBE','DESC','ASC',
-  'EXPLAIN','USE','SETTINGS','FORMAT','ARRAY','TUPLE','MAP','PREWHERE','FINAL',
-  'SAMPLE','TOP','ANTI','SEMI','ANY','ASOF','GLOBAL','LOCAL','ILIKE','USING',
-]);
-const SQL_FUNCS = new Set([
-  'count','sum','avg','min','max','round','floor','ceil','abs','length',
-  'lower','upper','substring','concat','toString','toDate','toDateTime',
-  'toStartOfMonth','toStartOfWeek','toStartOfDay','toStartOfHour','now',
-  'today','yesterday','formatDateTime','if','multiIf','coalesce','isNull',
-  'isNotNull','quantile','quantiles','uniq','uniqExact','any','anyLast',
-  'groupArray','groupUniqArray','arrayJoin','arrayMap','arrayFilter',
-  'splitByChar','toUInt32','toInt64','toFloat64','toUInt8','greatest','least',
-]);
-
-// #25: backward-compatible optional second arg. Existing callers (formatter,
-// highlighter) pass nothing and get the built-in sets.
-function tokenize(sql, opts = {}) {
-  const keywords = opts.keywords || SQL_KEYWORDS;
-  const funcs = opts.funcs || SQL_FUNCS;
-  const out = [];
-  let i = 0;
-  const n = sql.length;
-  while (i < n) {
-    const c = sql[i];
-    if (c === '-' && sql[i + 1] === '-') {
-      let j = i; while (j < n && sql[j] !== '\n') j++;
-      out.push({ t: 'comment', v: sql.slice(i, j), i }); i = j; continue;
-    }
-    if (c === '/' && sql[i + 1] === '*') {
-      let j = i + 2; while (j < n - 1 && !(sql[j] === '*' && sql[j + 1] === '/')) j++;
-      j = Math.min(n, j + 2);
-      out.push({ t: 'comment', v: sql.slice(i, j), i }); i = j; continue;
-    }
-    if (c === "'" || c === '"' || c === '`') {
-      let j = i + 1;
-      while (j < n && sql[j] !== c) { if (sql[j] === '\\') j++; j++; }
-      j = Math.min(n, j + 1);
-      out.push({ t: c === '`' ? 'ident' : 'string', v: sql.slice(i, j), i }); i = j; continue;
-    }
-    if (/[0-9]/.test(c)) {
-      let j = i;
-      while (j < n && /[0-9.eE+\-]/.test(sql[j])) {
-        if ((sql[j] === '+' || sql[j] === '-') && !/[eE]/.test(sql[j - 1])) break;
-        j++;
-      }
-      out.push({ t: 'number', v: sql.slice(i, j), i }); i = j; continue;
-    }
-    if (/[a-zA-Z_]/.test(c)) {
-      let j = i;
-      while (j < n && /[a-zA-Z0-9_]/.test(sql[j])) j++;
-      const word = sql.slice(i, j);
-      const upper = word.toUpperCase();
-      let type = 'ident';
-      if (keywords.has(upper)) type = 'keyword';
-      else if (funcs.has(word)) type = 'func';
-      out.push({ t: type, v: word, i }); i = j; continue;
-    }
-    if (/[=<>!+\-*/%(),.;]/.test(c)) { out.push({ t: 'op', v: c, i }); i++; continue; }
-    let j = i;
-    while (j < n && /\s/.test(sql[j])) j++;
-    if (j > i) { out.push({ t: 'ws', v: sql.slice(i, j), i }); i = j; continue; }
-    out.push({ t: 'other', v: c, i }); i++;
-  }
-  return out;
-}
-const highlightSql = (sql, opts) => tokenize(sql, opts);
-
-function SqlHighlighter({ sql }) {
-  const tokens = React.useMemo(() => tokenize(sql), [sql]);
-  return (
-    <>
-      {tokens.map((tk, i) => tk.t === 'ws' ? tk.v : {tk.v})}
-      {'\n'}
-    
-  );
-}
-
-// ── caret/position geometry (monospace fast-path; whitespace:pre, no wrap) ────
-let _measCanvas;
-function charWidthFor(px) {
-  _measCanvas = _measCanvas || document.createElement('canvas');
-  const ctx = _measCanvas.getContext('2d');
-  ctx.font = `${px}px "JetBrains Mono","SF Mono",ui-monospace,monospace`;
-  return ctx.measureText('0').width;
-}
-function caretXY(value, pos, ta, fontSize, lhPx, padX, padY) {
-  const before = value.slice(0, pos);
-  const line = before.split('\n').length - 1;
-  const col = pos - (before.lastIndexOf('\n') + 1);
-  const cw = charWidthFor(fontSize);
-  return { x: padX + col * cw - (ta ? ta.scrollLeft : 0), y: padY + line * lhPx - (ta ? ta.scrollTop : 0) };
-}
-function posFromXY(value, clientX, clientY, rect, ta, fontSize, lhPx, padX, padY) {
-  const x = clientX - rect.left + ta.scrollLeft - padX;
-  const y = clientY - rect.top + ta.scrollTop - padY;
-  const line = Math.floor(y / lhPx);
-  const lines = value.split('\n');
-  if (line < 0 || line >= lines.length) return null;
-  const col = Math.round(x / charWidthFor(fontSize));
-  let pos = 0;
-  for (let k = 0; k < line; k++) pos += lines[k].length + 1;
-  return pos + Math.max(0, Math.min(col, lines[line].length));
-}
-function wordAt(value, pos) {
-  if (pos == null) return null;
-  let s = pos, e = pos;
-  while (s > 0 && /[A-Za-z0-9_]/.test(value[s - 1])) s--;
-  while (e < value.length && /[A-Za-z0-9_]/.test(value[e])) e++;
-  if (s === e) return null;
-  return { word: value.slice(s, e), from: s, to: e };
-}
-
-// ── bracket matching (#24) ───────────────────────────────────────────────────
-const OPEN = { '(': ')', '[': ']', '{': '}' };
-const CLOSE = { ')': '(', ']': '[', '}': '{' };
-function matchBracketAt(value, caret) {
-  const tryFrom = (idx, dir) => {
-    const ch = value[idx];
-    if (dir === 1 && OPEN[ch]) {
-      let depth = 0;
-      for (let k = idx; k < value.length; k++) {
-        if (value[k] === ch) depth++;
-        else if (value[k] === OPEN[ch]) { depth--; if (depth === 0) return [idx, k]; }
-      }
-    } else if (dir === -1 && CLOSE[ch]) {
-      let depth = 0;
-      for (let k = idx; k >= 0; k--) {
-        if (value[k] === ch) depth++;
-        else if (value[k] === CLOSE[ch]) { depth--; if (depth === 0) return [k, idx]; }
-      }
-    }
-    return null;
-  };
-  return tryFrom(caret, 1) || (caret > 0 ? tryFrom(caret - 1, -1) : null);
-}
-
-// ── transparent overlay: only mark backgrounds, never the token render path ──
-function MarkOverlay({ value, marks, accent }) {
-  if (!marks.length) return null;
-  const bgFor = (cls) =>
-    cls === 'active' ? `color-mix(in oklab, ${accent} 62%, transparent)`
-    : cls === 'match' ? `color-mix(in oklab, ${accent} 26%, transparent)`
-    : `color-mix(in oklab, ${accent} 34%, transparent)`; // bracket
-  const pts = new Set([0, value.length]);
-  marks.forEach((m) => { pts.add(m.start); pts.add(m.end); });
-  const sorted = [...pts].filter((p) => p >= 0 && p <= value.length).sort((a, b) => a - b);
-  const out = [];
-  for (let i = 0; i < sorted.length - 1; i++) {
-    const a = sorted[i], b = sorted[i + 1];
-    if (a === b) continue;
-    const seg = value.slice(a, b);
-    const cover = marks.filter((m) => m.start <= a && m.end >= b);
-    if (cover.length) {
-      const cls = cover.some((m) => m.cls === 'active') ? 'active'
-        : cover.some((m) => m.cls === 'match') ? 'match' : cover[0].cls;
-      out.push({seg});
-    } else out.push(seg);
-  }
-  out.push('\n');
-  return out;
-}
-
-function SqlEditor({ value, onChange, accent = '#FF6B35', fontSize = 13, density = 'comfortable' }) {
-  const taRef = React.useRef(null);
-  const preRef = React.useRef(null);
-  const overlayRef = React.useRef(null);
-  const lineRef = React.useRef(null);
-  const wrapRef = React.useRef(null);
-  const pendingSel = React.useRef(null);
-
-  const [caret, setCaret] = React.useState(0);
-  const [selEnd, setSelEnd] = React.useState(0);
-
-  // completion / search / popover state
-  const completions = React.useMemo(() => buildCompletions(window.SCHEMA), []);
-  const [ac, setAc] = React.useState(null);        // {items, active, ctx}
-  const [searchOpen, setSearchOpen] = React.useState(false);
-  const [query, setQuery] = React.useState('');
-  const [replace, setReplace] = React.useState('');
-  const [sopts, setSopts] = React.useState({ caseSensitive: false, wholeWord: false, regex: false });
-  const [showReplace, setShowReplace] = React.useState(false);
-  const [activeMatch, setActiveMatch] = React.useState(0);
-  const [hover, setHover] = React.useState(null);
-  const searchInputRef = React.useRef(null);
-  const hoverTimer = React.useRef(null);
-
-  const lineHeight = density === 'compact' ? 1.5 : 1.7;
-  const padY = density === 'compact' ? 8 : 12;
-  const padX = 14;
-  const lhPx = fontSize * lineHeight;
-  const lines = value.split('\n');
-
-  React.useLayoutEffect(() => {
-    if (pendingSel.current != null && taRef.current) {
-      const [s, e] = pendingSel.current;
-      taRef.current.selectionStart = s;
-      taRef.current.selectionEnd = e;
-      pendingSel.current = null;
-      setCaret(s); setSelEnd(e);
-    }
-  });
-
-  const apply = (newVal, s, e = s) => { pendingSel.current = [s, e]; onChange(newVal); };
-
-  const syncCaret = () => {
-    const ta = taRef.current; if (!ta) return;
-    setCaret(ta.selectionStart); setSelEnd(ta.selectionEnd);
-  };
-
-  const onScroll = () => {
-    const ta = taRef.current;
-    if (preRef.current) { preRef.current.scrollTop = ta.scrollTop; preRef.current.scrollLeft = ta.scrollLeft; }
-    if (overlayRef.current) { overlayRef.current.scrollTop = ta.scrollTop; overlayRef.current.scrollLeft = ta.scrollLeft; }
-    if (lineRef.current) lineRef.current.scrollTop = ta.scrollTop;
-    setAc(null);
-  };
-
-  // ── autocomplete trigger ───────────────────────────────────────────────────
-  const refreshComplete = (val, pos) => {
-    const ctx = completionContext(val, pos);
-    if (!ctx.qualified && ctx.word.length < 1) { setAc(null); return; }
-    const items = rankCompletions(completions, ctx);
-    if (!items.length) { setAc(null); return; }
-    setAc({ items, active: 0, ctx });
-  };
-
-  const acceptCompletion = (it) => {
-    if (!ac) return;
-    const { from, to } = ac.ctx;
-    const ins = it.insert;
-    const newVal = value.slice(0, from) + ins + value.slice(to);
-    const caretPos = from + ins.length;
-    apply(newVal, caretPos);
-    setAc(null);
-  };
-
-  // ── key handling: tab, brackets, autoclose, autocomplete nav, cmd+f ──────────
-  const onKeyDown = (e) => {
-    const ta = e.target;
-    const s = ta.selectionStart, en = ta.selectionEnd;
-
-    // Cmd/Ctrl+F — registered on the textarea so the browser's native find
-    // doesn't intercept first (resolved design decision).
-    if ((e.metaKey || e.ctrlKey) && (e.key === 'f' || e.key === 'F')) {
-      e.preventDefault();
-      setSearchOpen(true);
-      requestAnimationFrame(() => searchInputRef.current?.focus());
-      return;
-    }
-
-    // autocomplete navigation
-    if (ac) {
-      if (e.key === 'ArrowDown') { e.preventDefault(); setAc({ ...ac, active: (ac.active + 1) % ac.items.length }); return; }
-      if (e.key === 'ArrowUp') { e.preventDefault(); setAc({ ...ac, active: (ac.active - 1 + ac.items.length) % ac.items.length }); return; }
-      if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); acceptCompletion(ac.items[ac.active]); return; }
-      if (e.key === 'Escape') { e.preventDefault(); setAc(null); return; }
-    }
-
-    if (e.key === 'Tab') {
-      e.preventDefault();
-      apply(value.slice(0, s) + '  ' + value.slice(en), s + 2);
-      return;
-    }
-
-    // auto-close pairs + wrap selection (#24)
-    if (OPEN[e.key]) {
-      e.preventDefault();
-      const close = OPEN[e.key];
-      if (s !== en) { // wrap
-        apply(value.slice(0, s) + e.key + value.slice(s, en) + close + value.slice(en), s + 1, en + 1);
-      } else {
-        apply(value.slice(0, s) + e.key + close + value.slice(en), s + 1);
-      }
-      return;
-    }
-    if ((e.key === "'" || e.key === '"' || e.key === '`')) {
-      const q = e.key;
-      if (s !== en) { e.preventDefault(); apply(value.slice(0, s) + q + value.slice(s, en) + q + value.slice(en), s + 1, en + 1); return; }
-      if (value[s] === q) { e.preventDefault(); apply(value, s + 1); return; } // type over
-      e.preventDefault(); apply(value.slice(0, s) + q + q + value.slice(en), s + 1); return;
-    }
-    if (CLOSE[e.key] && value[s] === e.key && s === en) { // type over closer
-      e.preventDefault(); apply(value, s + 1); return;
-    }
-    if (e.key === 'Backspace' && s === en && s > 0) {
-      const prev = value[s - 1], next = value[s];
-      if ((OPEN[prev] && next === OPEN[prev]) || ((prev === "'" || prev === '"' || prev === '`') && next === prev)) {
-        e.preventDefault(); apply(value.slice(0, s - 1) + value.slice(s + 1), s - 1); return;
-      }
-    }
-  };
-
-  const onChangeRaw = (e) => {
-    const val = e.target.value;
-    const pos = e.target.selectionStart;
-    onChange(val);
-    setCaret(pos); setSelEnd(pos);
-    refreshComplete(val, pos);
-  };
-
-  // ── search ───────────────────────────────────────────────────────────────
-  const matches = React.useMemo(
-    () => (searchOpen && query ? findMatches(value, query, sopts) : []),
-    [searchOpen, query, value, sopts]);
-  React.useEffect(() => { setActiveMatch((a) => matches.length ? Math.min(a, matches.length - 1) : 0); }, [matches.length]);
-
-  const scrollToMatch = (m) => {
-    const ta = taRef.current; if (!ta || !m) return;
-    const line = value.slice(0, m.start).split('\n').length - 1;
-    const top = line * lhPx;
-    if (top < ta.scrollTop + padY || top > ta.scrollTop + ta.clientHeight - lhPx - padY) {
-      ta.scrollTop = Math.max(0, top - ta.clientHeight / 2);
-      onScroll();
-    }
-  };
-  const gotoMatch = (idx) => { const i = (idx + matches.length) % matches.length; setActiveMatch(i); scrollToMatch(matches[i]); };
-  const doReplace = () => {
-    const m = matches[activeMatch]; if (!m) return;
-    apply(value.slice(0, m.start) + replace + value.slice(m.end), m.start + replace.length);
-    requestAnimationFrame(() => searchInputRef.current?.focus());
-  };
-  const doReplaceAll = () => {
-    if (!matches.length) return;
-    let out = '', last = 0;
-    for (const m of matches) { out += value.slice(last, m.start) + replace; last = m.end; }
-    out += value.slice(last);
-    apply(out, Math.min(caret, out.length));
-  };
-  const closeSearch = () => { setSearchOpen(false); requestAnimationFrame(() => taRef.current?.focus()); };
-
-  // ── marks (search + bracket pair) ──────────────────────────────────────────
-  const marks = React.useMemo(() => {
-    const ms = [];
-    if (searchOpen) matches.forEach((m, i) => ms.push({ start: m.start, end: m.end, cls: i === activeMatch ? 'active' : 'match' }));
-    if (!searchOpen && caret === selEnd) {
-      const bp = matchBracketAt(value, caret);
-      if (bp) { ms.push({ start: bp[0], end: bp[0] + 1, cls: 'bracket' }); ms.push({ start: bp[1], end: bp[1] + 1, cls: 'bracket' }); }
-    }
-    return ms;
-  }, [searchOpen, matches, activeMatch, value, caret, selEnd]);
-
-  // ── signature help (#27) ────────────────────────────────────────────────────
-  const sig = React.useMemo(() => {
-    if (ac || caret !== selEnd) return null;
-    const sc = signatureContext(value, caret);
-    if (!sc) return null;
-    const meta = REF_FUNCTIONS[sc.name];
-    if (!meta) return null;
-    return { ...sc, sig: meta.sig, ret: meta.ret };
-  }, [value, caret, selEnd, ac]);
-
-  // ── hover docs (#27) ────────────────────────────────────────────────────────
-  const onMouseMove = (e) => {
-    clearTimeout(hoverTimer.current);
-    const ta = taRef.current; if (!ta) { return; }
-    const cx = e.clientX, cy = e.clientY;
-    hoverTimer.current = setTimeout(() => {
-      const rect = ta.getBoundingClientRect();
-      const pos = posFromXY(value, cx, cy, rect, ta, fontSize, lhPx, padX, padY);
-      const w = wordAt(value, pos);
-      if (!w) { setHover(null); return; }
-      const fn = REF_FUNCTIONS[w.word];
-      const kw = REF_KEYWORD_DOCS[w.word.toUpperCase()];
-      if (fn) setHover({ x: cx, y: cy, sig: fn.sig, ret: fn.ret, doc: fn.desc });
-      else if (kw) setHover({ x: cx, y: cy, title: w.word.toUpperCase(), doc: kw });
-      else setHover(null);
-    }, 350);
-  };
-  const onMouseLeave = () => { clearTimeout(hoverTimer.current); setHover(null); };
-
-  const sharedText = {
-    margin: 0, padding: `${padY}px ${padX}px`, fontFamily: 'inherit', fontSize: 'inherit',
-    lineHeight: 'inherit', whiteSpace: 'pre', border: 'none', position: 'absolute', inset: 0,
-  };
-  const caretCoords = caretXY(value, caret, taRef.current, fontSize, lhPx, padX, padY);
-  // Screen-space caret position for body-portaled popovers (so the editor's
-  // overflow:hidden can't clip them). Falls back to 0,0 before first mount.
-  const taRect = taRef.current ? taRef.current.getBoundingClientRect() : null;
-  const popCoords = {
-    cx: (taRect ? taRect.left : 0) + caretCoords.x,
-    cy: (taRect ? taRect.top : 0) + caretCoords.y,
-    lhPx,
-    vw: typeof window !== 'undefined' ? window.innerWidth : 1280,
-    vh: typeof window !== 'undefined' ? window.innerHeight : 800,
-    accent,
-  };
-
-  return (
-    
-
- {lines.map((_, i) =>
{i + 1}
)} -
- -
- {/* mark overlay (below tokens; transparent text, only backgrounds show) */} - - {/* token highlight */} - -