From 06b4f23cdc844b3ecaa170f1ff5ff710f2d9ffc9 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Tue, 23 Jun 2026 18:55:44 +0200 Subject: [PATCH 01/11] chore(design): import Altinity Play handoff bundle into design/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verbatim snapshot of the "sql browser" Claude Design project's design_handoff_altinity_play/ bundle — the UI source-of-truth for the editor-enhancement track (#22 → #23–#27). README is the full spec (design tokens, region-by-region layout, per-issue editor reference); the .jsx files are the React/Babel reference implementations to port. Reference only — not built into dist/sql.html (esbuild bundles only src/main.js) and outside the test coverage globs (src/**/*.js). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VxARMjGin32SxX95TCuzQw --- design/Altinity Play.html | 212 ++++ design/IMPORTED.md | 14 + design/ISSUE-publish-as-markdown.md | 87 ++ design/Login.html | 304 ++++++ design/README.md | 1006 +++++++++++++++++++ design/app.jsx | 340 +++++++ design/components.jsx | 1429 +++++++++++++++++++++++++++ design/data.jsx | 188 ++++ design/editor-complete.jsx | 215 ++++ design/editor-data.jsx | 102 ++ design/editor-search.jsx | 143 +++ design/sql-editor.jsx | 514 ++++++++++ design/tweaks-panel.jsx | 425 ++++++++ 13 files changed, 4979 insertions(+) create mode 100644 design/Altinity Play.html create mode 100644 design/IMPORTED.md create mode 100644 design/ISSUE-publish-as-markdown.md create mode 100644 design/Login.html create mode 100644 design/README.md create mode 100644 design/app.jsx create mode 100644 design/components.jsx create mode 100644 design/data.jsx create mode 100644 design/editor-complete.jsx create mode 100644 design/editor-data.jsx create mode 100644 design/editor-search.jsx create mode 100644 design/sql-editor.jsx create mode 100644 design/tweaks-panel.jsx diff --git a/design/Altinity Play.html b/design/Altinity Play.html new file mode 100644 index 0000000..f069832 --- /dev/null +++ b/design/Altinity Play.html @@ -0,0 +1,212 @@ + + + + + +Altinity Play — Redesigned + + + + + + +
+ + + + + + + + + + + + + + + + + + + diff --git a/design/IMPORTED.md b/design/IMPORTED.md new file mode 100644 index 0000000..38527e7 --- /dev/null +++ b/design/IMPORTED.md @@ -0,0 +1,14 @@ +# 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 zero-dependency 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 new file mode 100644 index 0000000..e892fd7 --- /dev/null +++ b/design/ISSUE-publish-as-markdown.md @@ -0,0 +1,87 @@ +# 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
new file mode 100644
index 0000000..2433d46
--- /dev/null
+++ b/design/Login.html
@@ -0,0 +1,304 @@
+
+
+
+
+
+Altinity SQL Browser — Sign in
+
+
+
+
+
+
+
+ + + + + + + + + + + + diff --git a/design/README.md b/design/README.md new file mode 100644 index 0000000..6747051 --- /dev/null +++ b/design/README.md @@ -0,0 +1,1006 @@ +# 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
new file mode 100644
index 0000000..8ae9676
--- /dev/null
+++ b/design/app.jsx
@@ -0,0 +1,340 @@
+// 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 new file mode 100644 index 0000000..16a05f2 --- /dev/null +++ b/design/components.jsx @@ -0,0 +1,1429 @@ +// 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 new file mode 100644 index 0000000..1bd1317 --- /dev/null +++ b/design/data.jsx @@ -0,0 +1,188 @@ +// 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 new file mode 100644 index 0000000..50f497a --- /dev/null +++ b/design/editor-complete.jsx @@ -0,0 +1,215 @@ +// 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 new file mode 100644 index 0000000..0d8ed7b --- /dev/null +++ b/design/editor-data.jsx @@ -0,0 +1,102 @@ +// 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 new file mode 100644 index 0000000..5050889 --- /dev/null +++ b/design/editor-search.jsx @@ -0,0 +1,143 @@ +// 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 new file mode 100644 index 0000000..a795425 --- /dev/null +++ b/design/sql-editor.jsx @@ -0,0 +1,514 @@ +// 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 */} + +