From 8bdcdf8c284a6e90741d6f74cf4eaa500964d90f Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 15 Sep 2026 17:35:53 +0545 Subject: [PATCH 001/111] Polish the interface and add four themes A long pass over the chrome, the connection flow and the theme system. Fields and controls - One frame for every text field in the app, via `.field-surface`: one radius, a 2px border that looks identical focused and unfocused, and focus as a 2px outline sitting flush against it. No shadows, no fills, no layout shift. - `.cell-quiet` for picker tiles: same geometry, but the border only appears on hover or focus. Thirty tiles each drawing a permanent frame read as thirty highlighted things. - `.hit-area` no longer overrides an element's own `absolute`/`fixed`/`sticky` position, which was knocking absolutely positioned buttons out of their box. Connection modal - The picker step lost its redundant subtitle, and the title, search and rescan now share one row. Engine tiles pack at 11.5rem instead of 230px. - The detail step runs full width on a container grid: host, port, database, user and password on one row where the panel allows, falling back to the six-column two-row form when it does not. Toggles and the optional name share one band instead of owning a section each. - Connections persist before the connect attempt, so a failed connect no longer loses them. Per-engine defaults stop seeding every driver with `postgres`. - Mouse back/forward navigate the steps. Sidebar - VS Code-style icon tab strip in place of the collapsible sections, with per-tab empty states and a shown/total count. `Mod+Shift+1..5` and `Mod+Alt+Left/Right` move between tabs. Themes - Claude, Moss and Orchid in light and dark, plus Matte, a fully achromatic black. Every pair measured OKLCH to WCAG rather than eyeballed. - `index.html`'s boot list was two themes behind, so picking obsidian or clay booted into the OS default and repainted after load. All 26 ids now verified in sync across the registry, the CSS, the Monaco specs and the boot script. Fixes - JSON viewer was calling `editor.updateOptions` on a variable that does not exist. - Timestamp cells routed every keystroke through `commitEdit`, so one character ended the edit. - A filter value that cannot parse as the column's type now returns no rows instead of erroring out of the query. - Record and text views stopped mounting a control per row. Easter eggs - `broisnees` in the table search opens VACUUM, a snake played on a data grid where the predicate rotates. `crash` in the sidebar filter throws for real, and the boundary recovers. `clear` clears the search. --- DESIGN_SYSTEM.md | 81 +- index.html | 17 +- src-tauri/src/db/query.rs | 48 ++ src/App.svelte | 7 + src/app.css | 360 ++++++-- src/lib/components/AboutDialog.svelte | 34 +- src/lib/components/AdvisorPage.svelte | 12 +- src/lib/components/AiChartRenderer.svelte | 2 +- src/lib/components/AiChat.svelte | 242 +++--- src/lib/components/AiModelPicker.svelte | 6 +- src/lib/components/AiSettingsDialog.svelte | 76 +- src/lib/components/AiSidebar.svelte | 108 +-- src/lib/components/AppErrorBoundary.svelte | 4 +- src/lib/components/AppLockScreen.svelte | 6 +- src/lib/components/AppearanceMenu.svelte | 30 +- src/lib/components/ArrayCellEditor.svelte | 26 +- src/lib/components/BackupPage.svelte | 36 +- src/lib/components/CarbonMeterChart.svelte | 14 +- src/lib/components/CellQuickLook.svelte | 80 +- src/lib/components/ChartView.svelte | 60 +- src/lib/components/ChartsPage.svelte | 58 +- src/lib/components/ChoroplethChart.svelte | 10 +- src/lib/components/CloudflareLogin.svelte | 28 +- src/lib/components/ColumnStatsPanel.svelte | 4 +- src/lib/components/CommandPalette.svelte | 79 +- src/lib/components/ConfirmDialog.svelte | 43 +- src/lib/components/ConnectionModal.svelte | 814 +++++++++++------- .../components/ConnectionsSidebarPanel.svelte | 8 +- src/lib/components/CopilotLogin.svelte | 6 +- src/lib/components/CrashNow.svelte | 14 + .../components/CreateDatabaseDialog.svelte | 42 +- src/lib/components/CreateSchemaDialog.svelte | 16 +- .../components/CreateSequenceDialog.svelte | 22 +- src/lib/components/CreateTableDialog.svelte | 18 +- src/lib/components/CreateTriggerDialog.svelte | 30 +- .../components/DangerousActionDialog.svelte | 6 +- src/lib/components/DashboardPage.svelte | 50 +- src/lib/components/DataDiffPage.svelte | 108 +-- src/lib/components/DataTable.svelte | 59 +- src/lib/components/DatabaseInfoDialog.svelte | 16 +- src/lib/components/DatabaseNameDialog.svelte | 22 +- src/lib/components/DatabaseSwitcher.svelte | 16 +- src/lib/components/DateFilterControl.svelte | 8 +- src/lib/components/DateFilterPicker.svelte | 8 +- src/lib/components/DateRangePicker.svelte | 6 +- src/lib/components/DateTimePicker.svelte | 23 +- src/lib/components/DdlConfirmDialog.svelte | 2 +- src/lib/components/DiagramsPage.svelte | 34 +- src/lib/components/DockerLaunchModal.svelte | 2 +- src/lib/components/DropDatabaseDialog.svelte | 8 +- src/lib/components/EntityRelationPage.svelte | 162 ++-- src/lib/components/EnumsPage.svelte | 12 +- src/lib/components/ErdCanvas.svelte | 8 +- src/lib/components/ExplainPlan.svelte | 22 +- src/lib/components/ExtensionsPage.svelte | 67 +- .../components/ExtensionsSidebarPanel.svelte | 28 +- src/lib/components/FieldSelect.svelte | 4 +- src/lib/components/FindReplaceDialog.svelte | 32 +- src/lib/components/FkSubviewPanel.svelte | 28 +- src/lib/components/ForeignKeyDialog.svelte | 26 +- src/lib/components/GenerateSqlDialog.svelte | 8 +- src/lib/components/GeoMiniMap.svelte | 4 +- src/lib/components/GeometryCellViewer.svelte | 32 +- src/lib/components/InsertRowDialog.svelte | 4 +- src/lib/components/InsertValuePicker.svelte | 10 +- .../components/InstanceInsightsPage.svelte | 64 +- src/lib/components/JsonPathSuggest.svelte | 10 +- src/lib/components/JsonTree.svelte | 14 +- src/lib/components/JsonViewer.svelte | 6 +- src/lib/components/JsonViewerPage.svelte | 65 +- .../components/KeyboardShortcutsDialog.svelte | 26 +- src/lib/components/LicenseActivation.svelte | 6 +- src/lib/components/LicensePage.svelte | 8 +- src/lib/components/LogPanel.svelte | 22 +- src/lib/components/LogsPage.svelte | 28 +- src/lib/components/MapPage.svelte | 36 +- src/lib/components/McpPanel.svelte | 42 +- src/lib/components/MermaidViewer.svelte | 10 +- src/lib/components/NotebookEditor.svelte | 22 +- src/lib/components/ObjectsPage.svelte | 20 +- src/lib/components/Onboarding.svelte | 22 +- src/lib/components/OrmRunner.svelte | 20 +- src/lib/components/OrmSchemaPage.svelte | 18 +- src/lib/components/PaneSnapshot.svelte | 20 +- src/lib/components/PasswordInput.svelte | 2 +- src/lib/components/PinChallengeDialog.svelte | 6 +- src/lib/components/PinSetupDialog.svelte | 10 +- src/lib/components/ProviderAuthPanel.svelte | 2 +- src/lib/components/ProviderConnect.svelte | 28 +- src/lib/components/QueryHistoryPanel.svelte | 46 +- src/lib/components/QueryLogConsole.svelte | 26 +- src/lib/components/RedisKeyspacePage.svelte | 76 +- src/lib/components/RelationTreeNode.svelte | 70 +- src/lib/components/RelationTreePage.svelte | 78 +- src/lib/components/ReportIssueDialog.svelte | 30 +- src/lib/components/RowDetailPanel.svelte | 26 +- src/lib/components/RowExpandViewer.svelte | 14 +- src/lib/components/SchemaPage.svelte | 64 +- src/lib/components/SchemaTimelinePage.svelte | 166 ++-- src/lib/components/SearchPage.svelte | 48 +- src/lib/components/SearchableMenu.svelte | 6 +- src/lib/components/SecurityPage.svelte | 82 +- src/lib/components/SecuritySqlModal.svelte | 6 +- src/lib/components/SelectMenu.svelte | 4 +- src/lib/components/SettingsDialog.svelte | 77 +- src/lib/components/Sidebar.svelte | 635 ++++++++------ src/lib/components/SqlConsole.svelte | 76 +- src/lib/components/SqlEditor.svelte | 2 +- src/lib/components/StatusBar.svelte | 156 ++-- src/lib/components/StructureView.svelte | 138 +-- src/lib/components/StudioShell.svelte | 280 +++--- src/lib/components/TabBar.svelte | 24 +- src/lib/components/TabLoading.svelte | 4 +- src/lib/components/TableJsonView.svelte | 12 +- src/lib/components/TableLoading.svelte | 4 +- src/lib/components/TableRecordView.svelte | 195 +++-- src/lib/components/TableTextView.svelte | 54 +- src/lib/components/TableToolbar.svelte | 138 +-- src/lib/components/TitleBar.svelte | 18 +- src/lib/components/TrialExpiredScreen.svelte | 2 +- src/lib/components/UpdateDialog.svelte | 36 +- src/lib/components/UrlPreviewTooltip.svelte | 2 +- src/lib/components/VacuumGame.svelte | 403 +++++++++ src/lib/components/VectorCellViewer.svelte | 24 +- src/lib/components/VirtualColumnsPanel.svelte | 66 +- src/lib/components/WindowControls.svelte | 2 +- .../components/notebook/MarkdownCell.svelte | 22 +- src/lib/components/notebook/SqlCell.svelte | 44 +- src/lib/components/ui/alert/alert.svelte | 2 +- src/lib/components/ui/button/button.svelte | 20 +- .../components/ui/calendar/calendar.svelte | 2 +- .../ui/calendar/range-calendar.svelte | 2 +- .../ui/command/command-group.svelte | 2 +- .../ui/command/command-input.svelte | 4 +- .../ui/command/command-shortcut.svelte | 4 +- .../ui/context-menu/context-menu-label.svelte | 2 +- .../context-menu/context-menu-shortcut.svelte | 2 +- .../ui/dialog/dialog-content.svelte | 2 +- .../dropdown-menu/dropdown-menu-label.svelte | 2 +- .../dropdown-menu-shortcut.svelte | 2 +- .../ui/input-group/input-group.svelte | 2 +- src/lib/components/ui/input/input.svelte | 4 +- .../ui/select/select-group-heading.svelte | 2 +- .../components/ui/select/select-label.svelte | 2 +- .../ui/select/select-trigger.svelte | 2 +- src/lib/components/ui/sonner/sonner.svelte | 4 +- .../components/ui/textarea/textarea.svelte | 2 +- src/lib/games/easter-eggs.js | 39 + src/lib/games/vacuum.js | 267 ++++++ src/lib/games/vacuum.test.js | 234 +++++ src/lib/shortcuts.js | 5 +- src/lib/stores/connections.js | 31 +- src/lib/stores/layout.js | 21 + src/lib/stores/settings.js | 142 ++- src/lib/studio-tabs.js | 5 +- src/lib/themes/app-themes.css | 408 +++++++++ src/lib/themes/monaco-presets.js | 274 ++++++ src/lib/themes/registry.js | 187 +++- 158 files changed, 5827 insertions(+), 2769 deletions(-) create mode 100644 src/lib/components/CrashNow.svelte create mode 100644 src/lib/components/VacuumGame.svelte create mode 100644 src/lib/games/easter-eggs.js create mode 100644 src/lib/games/vacuum.js create mode 100644 src/lib/games/vacuum.test.js diff --git a/DESIGN_SYSTEM.md b/DESIGN_SYSTEM.md index 852b826c..603b7959 100644 --- a/DESIGN_SYSTEM.md +++ b/DESIGN_SYSTEM.md @@ -46,8 +46,35 @@ mismatched control heights, or one-off selected states. `font-mono tabular-nums`. ### Base size -`1rem = --app-font-size = 14px`. Everything scales off this; the app supports -zoom by design, so **rem-based tokens only** (the `text-ui-*` classes are rem). +`1rem = --app-font-size`. Each platform's 100% rung is its own comfortable +reading size, so "zoom 0" means the same thing everywhere: + +| Platform | 100% root | Why | +|---|---|---| +| Linux, Windows | 18px | 1x-DPI desktops; a 14px stroke is too thin to read reliably | +| macOS | 14px | Retina plus SF's own hinting; 18 is oversized there | + +Windows used to reach ~17.5px by shipping a **1.25 default zoom** instead, which +made its 100% rung a lie and left no headroom below it. That is gone: zoom 1 is +the default on every platform. The app supports zoom by design, so **never +hard-code a px font size**. + +**The root steps by one whole pixel per zoom rung**, taken from the rung's index +in `ZOOM_STEPS` rather than from `basePx × zoom`. Multiplying and rounding +collapses rungs into each other - at a 16px base, 110% and 115% both round to +18px - so the window stayed pixel-identical between them while the `--fs-*` +steps, rounded independently from different numerators, still moved. That is +zoom which changes the text and not the layout. Indexing guarantees a strictly +increasing root on every base, so a zoom step always moves both. + +The `text-ui-*` classes resolve to `--fs-*` rather than a rem calc. +`applySettings()` (`src/lib/stores/settings.js`) recomputes every step for the +active base size and zoom level and **rounds each one to a whole pixel**. The +rounding is the point: the old `calc(N / 14 * 1rem)` form only landed on whole +pixels when the root happened to be exactly 14px, so at the 15px Linux base a +13px caption rendered at 13.93px and WebKit rasterised it off the pixel grid. +That is what made the whole UI look soft at 100% zoom. Add a step by editing +`UI_TYPE_SCALE` in `settings.js` and its `:root` fallback in `app.css` together. --- @@ -97,9 +124,35 @@ literal colors. | `ring` | focus ring | **Opacity conventions (consistency matters most here):** -- Secondary text: `text-muted-foreground`. Tertiary: `text-muted-foreground/60`. +- Secondary text: `text-muted-foreground`. There is no contrast-safe tertiary + text tier. Measured on the dark theme, `--muted-foreground` is a clean 7.85:1 + at full strength, but every alpha step below **74%** drops under the 4.5:1 text + floor: `/70` is 4.27:1, `/60` 3.40:1, `/50` 2.69:1, `/40` 2.11:1. On light + themes the cutoff is 89%, so `/60` and below fail there too. **De-emphasise + with size, weight, or position - not with alpha on the text colour.** The + codebase still carries ~1,370 `text-muted-foreground/NN` uses from before this + was measured; treat each one as a bug when you touch its component, and never + add a new one to text a user has to read. +- Decorative marks that carry no meaning (separator dots, watermarks) may go + below the floor. Anything that carries meaning needs 3:1, which is 58% alpha + on dark and 69% on light. - Hairlines: `border-border` (structural) · `border-border/50` (internal dividers). - Idle icon buttons: `text-muted-foreground` → hover `text-foreground`. +- **Field chrome is one class: `.field-surface`** (`app.css`). It sets the field + radius and a 1px `--field-border` hairline, and nothing else - no fill, no + gradient, no shadow. Inputs, textareas, select triggers, the filter-row + controls, the connection form and the outline/secondary buttons all wear it, so + a text field and the select beside it are drawn the same way. Anything that + needs a background adds its own `bg-*`. +- **The border never changes.** Hover does not move it and neither does focus. + Focus is `outline: 2px solid var(--ring)` at `outline-offset: 1px`, sitting + just outside the edge - one focus language for fields and buttons, no layout + shift, and no shadow anywhere in the system. +- `--field-border` is `color-mix(in oklch, var(--foreground) 30%, var(--background))`, + derived per theme. It measures 1.87-1.96:1 on dark and 2.21-2.44:1 on light, so + it misses WCAG 1.4.11's 3:1 for a control boundary - a deliberate call for the + hairline look, with the visible-state burden carried by the focus outline + (4.63:1) instead. Do not raise it without raising the whole system together. - **Selected/active row (canonical):** `bg-accent text-foreground` for chrome; `bg-primary/10 text-foreground ring-1 ring-primary/25` for prominent pick lists (provider DB lists, account lists). **Pick one per context and never mix @@ -111,6 +164,12 @@ literal colors. ## 4. Radius +`--radius` is **absolute px**, not rem: a corner is a physical size and has no +reason to grow when the type scale or the zoom level does. 6px by default, 8px on +macOS, 7px on Windows. Fields and the buttons beside them use `--radius-field` +(10px), which is rounder on purpose; small buttons step down from it rather than +running their own ladder. + | Class | Use | |---|---| | `rounded-md` | buttons, icon buttons, inputs, menu items, chips | @@ -136,6 +195,17 @@ No other radii. No `rounded-xl`/`rounded-sm` in new code unless matching a - Always `shrink-0` on icons inside flex rows. - Square sizing via `size-*` (never `w-4 h-4`). - One icon set. No custom SVG unless truly bespoke. +- **`size-3` (12px) is the floor.** Below that a Lucide glyph is sub-pixel mush + at 1x DPI, which is where the old `size-2.5` RLS lock and Pro badge ended up. +- An icon that carries meaning on its own (table vs view, locked vs unlocked, + a status glyph) needs **3:1 against its surface**. On the dark theme that is + `text-muted-foreground` at 58% alpha or stronger, so anything at + `text-muted-foreground/50` or below fails. Do not stack `opacity-*` on top of + an already-thinned colour - they multiply. +- An icon button smaller than `size-6` needs the `.hit-area` class, which grows + the pointer/touch target to the WCAG 2.5.8 24x24 baseline without changing how + big the button looks. Keep 4px between neighbouring ones so the targets do not + overlap. --- @@ -245,8 +315,9 @@ flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-left transition-col hover:bg-muted/50 text-foreground/85 bg-primary/10 text-foreground ring-1 ring-primary/25 ``` -- Leading `size-4` icon (`text-muted-foreground/45` idle, `text-foreground` - selected), `shrink-0`. +- Leading `size-4` icon (`text-muted-foreground` idle, `text-foreground` + selected), `shrink-0`. The old `/45` measured 2.38:1 against `--background`, + under the 3:1 floor a meaning-carrying glyph needs. - Label: `min-w-0 flex-1 truncate` (+ `font-mono` for identifiers). - Trailing metadata: `text-ui-2xs text-muted-foreground/40`, `ml-auto` or after the flex-1 label; selected → trailing `Check size-3.5 text-primary`. diff --git a/index.html b/index.html index 22712fa5..87bb48ad 100644 --- a/index.html +++ b/index.html @@ -5,12 +5,21 @@ try { const raw = localStorage.getItem('stroke:settings') const parsed = raw ? JSON.parse(raw) : null + // Keep both lists in sync with src/lib/themes/registry.js. They were + // already a theme behind: obsidian and clay were missing, so choosing + // either meant booting into the OS default and repainting once the app + // loaded. const valid = [ - 'light', 'parchment', 'ice', 'github-light', 'light-high-contrast', - 'dark', 'graphite', 'midnight', 'vitesse', 'slate', 'forest', 'mono', - 'rose-pine', 'catppuccin', 'solarized', 'dark-high-contrast', + 'light', 'parchment', 'clay', 'claude-light', 'moss-light', 'orchid-light', + 'ice', 'github-light', 'light-high-contrast', + 'dark', 'obsidian', 'matte', 'claude', 'moss', 'orchid', 'graphite', 'midnight', + 'vitesse', 'slate', 'forest', 'mono', 'rose-pine', 'catppuccin', + 'solarized', 'dark-high-contrast', 'hotdog', + ] + const lightThemes = [ + 'light', 'parchment', 'clay', 'claude-light', 'moss-light', 'orchid-light', + 'ice', 'github-light', 'light-high-contrast', ] - const lightThemes = ['light', 'parchment', 'ice', 'github-light', 'light-high-contrast'] let theme = parsed?.theme if (!valid.includes(theme)) { // No saved preference - follow the OS diff --git a/src-tauri/src/db/query.rs b/src-tauri/src/db/query.rs index 6d2c7d1f..c7358a51 100644 --- a/src-tauri/src/db/query.rs +++ b/src-tauri/src/db/query.rs @@ -1123,6 +1123,43 @@ fn is_date_only(s: &str) -> bool { && b[8..10].iter().all(u8::is_ascii_digit) } +/// Whether `v` can be parsed as the type `cast` names. +/// +/// A filter value is typed by the user, so "asdf" on a uuid column is routine, +/// not exceptional - but the comparison binds it as `$1::uuid` and Postgres +/// rejects the whole statement, so the grid showed "invalid input syntax for +/// type uuid" where it should have shown an empty table. A value that cannot be +/// parsed also cannot equal any row, which is a *result*, not an error: callers +/// below turn a `false` here into a constant condition and the query returns +/// zero rows. +/// +/// Deliberately permissive about shape, strict only about what Postgres itself +/// would refuse. Timestamps are left to the server: the accepted grammar is far +/// wider than anything worth reimplementing here, and a malformed one is rare +/// enough that erroring is acceptable. +fn value_parses_as(cast: &str, v: &str) -> bool { + let t = v.trim(); + match cast { + "::bigint" => t.parse::().is_ok(), + "::float8" => t.parse::().is_ok(), + "::numeric" => { + !t.is_empty() + && t.parse::().is_ok() + } + "::boolean" => matches!( + t.to_ascii_lowercase().as_str(), + "t" | "f" | "true" | "false" | "y" | "n" | "yes" | "no" | "on" | "off" | "1" | "0" + ), + "::uuid" => { + // 32 hex digits, with or without the four dashes, optionally braced. + let core = t.trim_start_matches('{').trim_end_matches('}'); + let hex: Vec = core.chars().filter(|c| *c != '-').collect(); + hex.len() == 32 && hex.iter().all(|c| c.is_ascii_hexdigit()) + } + _ => true, + } +} + fn build_filter_condition( builder: &mut QueryBuilder, column: &str, @@ -1133,6 +1170,17 @@ fn build_filter_condition( ) -> Result<(), String> { let col = quoted_column(column)?; let cast = pg_param_cast(data_type); + // A value the column's type cannot represent matches nothing, so say that in + // SQL rather than letting the cast blow up the statement. `neq` inverts: a + // value no row can hold is distinct from every row, so every row matches. + if matches!(op, "eq" | "neq" | "gt" | "gte" | "lt" | "lte") { + let v = value.unwrap_or(""); + if !cast.is_empty() && !value_parses_as(cast, v) { + let cond = if op == "neq" { "TRUE" } else { "FALSE" }; + builder.push_condition(cond.to_string(), conjunct); + return Ok(()); + } + } // A bare date on a timestamp column means "the whole day", handled per-op // below (half-open [date, date+1) ranges). Pure `date`/`time` columns and // values that carry a time-of-day keep exact comparison. diff --git a/src/App.svelte b/src/App.svelte index 4c8f4b2d..a01c6e6e 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -5,6 +5,8 @@ import LicenseGate from './lib/components/LicenseGate.svelte' import AppLockGate from './lib/components/AppLockGate.svelte' import AppErrorBoundary from './lib/components/AppErrorBoundary.svelte' + import CrashNow from './lib/components/CrashNow.svelte' + import { crashArmed } from './lib/games/easter-eggs.js' import GlobalTooltip from './lib/components/GlobalTooltip.svelte' import { loadSettings, applySettings, installZoomShortcuts } from '$lib/stores/settings.js' import { installPlatformClass } from '$lib/platform.js' @@ -75,6 +77,11 @@ + + {#if $crashArmed}{/if} diff --git a/src/app.css b/src/app.css index eb4e0862..62841779 100644 --- a/src/app.css +++ b/src/app.css @@ -24,9 +24,53 @@ background regardless of CSS - the `.dark` block below flips it to dark for every dark theme. One declaration fixes every native dropdown at zero cost. */ color-scheme: light; - --radius: 0.375rem; + --radius: 8px; + /* Boundary of a form control (input, textarea, select trigger, combobox). + `--border` is the hairline/divider role and sits at ~1.3:1 against every + surface, which is fine for a divider and invisible on a field: the filter + row's three controls had no readable edge at all. Derived from the theme's + own foreground so all 16 themes get it without a per-theme entry; 44% is + the mix where it measures >= 3:1 (WCAG 1.4.11) on every surface in both + schemes. 36% measures ~2.4:1 on dark and ~2.6:1 on light, so it misses WCAG + 1.4.11's 3:1 for a control boundary - a deliberate call for the hairline + look, with the visible-state burden carried by the focus outline (4.63:1). */ + --field-border: color-mix(in oklch, var(--foreground) 36%, var(--background)); + /* 2px. A 1px edge at a 10px radius antialiases the corner arcs lighter than + the straight runs, so the border reads as four dashes with gaps at the + corners; 1.5px filled the arcs in but still sat back too far at rest. Two + whole pixels land on the device grid at 1x, so the edge is the same weight + all the way round and present without focus. */ + --field-border-width: 2px; + /* Fields, dropdown triggers and buttons all share ONE corner, and it is the + same one `rounded-lg` resolves to - a second radius for fields is how an + input ended up rounder than the button beside it. Absolute px, not rem: a + corner is a physical size and has no reason to grow when the type scale + does. */ + --radius-field: calc(var(--radius) + 2px); + /* No fill. A field is its border and its radius - a gradient made the edge + look brighter along the top than along the bottom, which reads as a badly + drawn border rather than as depth. */ --app-zoom: 1; --app-font-size: 14px; + /* UI type scale, in whole pixels. applySettings() recomputes these for the + active OS base size and zoom level and writes them back to the root; these + are the 14px-base fallbacks that hold for the first paint before JS runs. + Whole pixels are the point: a fractional font-size makes WebKit rasterise + glyphs off the pixel grid, which is what made UI text look soft at 100%. */ + --fs-3xl: 24px; + --lh-3xl: 30px; + --fs-2xl: 20px; + --lh-2xl: 28px; + --fs-xl: 18px; + --lh-xl: 26px; + --fs-lg: 16px; + --lh-lg: 24px; + --fs-base: 14px; + --fs-15: 15px; + --fs-sm: 13px; + --fs-xs: 12px; + --fs-2xs: 11px; + --fs-3xs: 10px; --editor-font-size: 14px; /* 1.5 ratio - matches VS Code / Cursor's default editor line height. */ --editor-line-height: 21px; @@ -121,24 +165,91 @@ html[data-icon-style="bold"] svg.lucide { stroke-width: 2.5px; } /* ── Focus ring - one premium treatment for every text field, app-wide ─────── Unlayered on purpose: unlayered CSS beats Tailwind's layered `ring-1 ring-ring` utilities, so this overrides the ~40 per-component focus styles - at once without editing each call site. Instead of a hard, fully-opaque 2px - double-line (border + ring stacked in the same color), it draws a calm 1px - accent border plus a soft translucent halo - the Linear/shadcn look. + at once without editing each call site. Scoped to `.field-surface`, which is + the set of fields that draw their own frame: an outline follows the element's + OWN radius, so on a square-cornered textarea nested inside a rounded card it + drew a hard rectangle across the card's corners. Those fields show focus on + their container with `focus-within` instead. The field's own border never + moves; focus is a 2px outline just outside it. `--ring` at full strength + measures 4.63:1 against --background, so the indicator carries on its own; + the previous neutral `foreground/55` was fine on its own but left a text + input and the select beside it wearing two different focus states in the same + filter row. Select triggers are buttons, so they are listed explicitly. aria-invalid fields keep their own destructive ring. */ -input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="file"]):not([type="color"]):not([aria-invalid="true"]):not(.no-focus-ring):focus-visible, -textarea:not([aria-invalid="true"]):not(.no-focus-ring):focus-visible { - border-color: color-mix(in oklch, var(--foreground) 55%, transparent); +.field-surface:not([aria-invalid="true"]):not(.no-focus-ring):focus-visible, +.cell-quiet:focus-visible, +[data-slot="select-trigger"]:not([aria-invalid="true"]):focus-visible, +[data-slot="select-trigger"][data-state="open"] { + /* The border is deliberately NOT touched here: a field's edge looks identical + whether it has focus or not. The indicator is an outline just outside that + edge - no shadow, no ring stacked on the border, no layout shift, and it + follows the corner radius. --ring measures 4.63:1 against --background. */ + outline: 2px solid var(--ring); + /* Offset 0: the ring sits directly against the outside of the border with no + gap between them. An outline never participates in layout, so it costs no + shift either way. */ + outline-offset: 0; box-shadow: none; - outline: none; } -/* Borderless search inputs (command palette, dropdown filters) opt out of the - halo - they already sit inside a bordered popover, so the ring reads as noise. */ -input.no-focus-ring:focus-visible { box-shadow: none; border-color: transparent; } +/* ── One frame for every text field in the app ─────────────────────────────── + `.field-surface` fixed the primitives, but roughly thirty fields across the + pages are written as bare / -

+

Prepended to the system prompt on every turn.

@@ -4560,7 +4560,7 @@ @@ -4570,7 +4570,7 @@

Chat history

-

+

Permanently delete all saved conversations for this connection.

-

+

{contextStats.pct}% used · auto-compresses at 30k tokens

@@ -4798,7 +4798,7 @@ class="font-mono text-ui font-semibold tabular-nums text-foreground" >{stat.value} - {stat.label} @@ -4811,7 +4811,7 @@ > {#each [{ color: "bg-primary/50", text: "Full history re-sent each turn for context." }, { color: "bg-primary/50", text: "Compresses at 30k, keeps last 10 turns." }, { color: "bg-primary/50", text: "Only schema for mentioned tables is injected." }, { color: "bg-warning/60", text: "Failed tool calls blocked after 2 retries." }] as item}
@@ -4967,7 +4967,7 @@ />

Ctrl+scroll to zoom · drag to pan · double-click to reset

@@ -4991,7 +4991,7 @@
{STEPS[step]} - {step + 1}/{STEPS.length} {#if isOmniroute} -

+

Connect a provider in the OmniRoute dashboard, then recheck.

{:else} @@ -816,24 +816,24 @@ Model ID below, so Continue works without retyping it. --> {#if suggestionsLoading} -
+
Loading what Ollama offers…
{:else if ollamaSuggestions.local.length || ollamaSuggestions.cloud.length} {#if ollamaSuggestions.local.length} -

Runs on this machine

+

Runs on this machine

{#each ollamaSuggestions.local.slice(0, 3) as m (m.id)}{@render suggestionRow(m)}{/each}
{/if} {#if ollamaSuggestions.cloud.length} -

+

Ollama Cloud · runs on their hardware

{#each ollamaSuggestions.cloud.slice(0, 5) as m (m.id)}{@render suggestionRow(m)}{/each}
-

+

Cloud models need ollama signin once - no download, and they are far larger than anything local.

@@ -841,7 +841,7 @@ {:else} -

+

Couldn't reach Ollama's model list. Pick one at

@@ -863,7 +863,7 @@
{/if}
-

+

{#if isOllama} Start it with ollama serve, or point the URL elsewhere. {:else} @@ -945,7 +945,7 @@ >

{preset.label}

- {#if preset.tag}

{preset.tag}

{/if} + {#if preset.tag}

{preset.tag}

{/if}
{#if selected}{@render checkBadge()}{/if} @@ -1004,7 +1004,7 @@ /> diff --git a/src/lib/components/AiSidebar.svelte b/src/lib/components/AiSidebar.svelte index 64c0edef..83eb9334 100644 --- a/src/lib/components/AiSidebar.svelte +++ b/src/lib/components/AiSidebar.svelte @@ -778,7 +778,7 @@
- + Assistant @@ -786,18 +786,18 @@
@@ -811,22 +811,22 @@
History {#if convList.length} - {/if}
{#if convList.length === 0} -

No saved chats yet

+

No saved chats yet

{:else} {#each convList as conv (conv.id)}
-
@@ -849,11 +849,11 @@

Ask anything

{#if schemaContext.activeTable} -

{schemaContext.activeSchema}.{schemaContext.activeTable}

+

{schemaContext.activeSchema}.{schemaContext.activeTable}

{:else if schemaContext.tables?.length} -

{schemaContext.activeSchema} · {schemaContext.tables.length} tables

+

{schemaContext.activeSchema} · {schemaContext.tables.length} tables

{:else} -

Knows your schema, table, and editor

+

Knows your schema, table, and editor

{/if}
@@ -873,8 +873,8 @@ class="flex items-center gap-2.5 bg-background px-3.5 py-2.5 text-left transition-colors hover:bg-muted/25 disabled:opacity-40" onclick={() => void send([s])} > - - {s} + + {s} {/each}
@@ -898,8 +898,8 @@ {:else if item.kind === 'thinking'}
- - {aiStatusHint || thinkingPhrase} + + {aiStatusHint || thinkingPhrase}
{:else if item.kind === 'streaming'} @@ -917,16 +917,16 @@
- - - + + +
@@ -935,14 +935,14 @@ {:else if part.type === 'mermaid'}
- diagram - + diagram +
{:else if part.type === 'error'} -

{part.content}

+

{part.content}

{:else if part.type === 'confirm_prompt'}
@@ -954,13 +954,13 @@ {@const codeOpen = !collapsed.has(codeKey)}
-
- - + +
{#if codeOpen}{/if} @@ -971,9 +971,9 @@ {:else if item.kind === 'executing'}
- - SQL - {item.sql} + + SQL + {item.sql}
{:else if item.kind === 'result'} @@ -982,11 +982,11 @@ {#if resOpen} @@ -996,10 +996,10 @@

{item.error}

{:else if item.rows.length === 0} -

No rows returned.

+

No rows returned.

{:else}
- {#if item.total > 15}

Showing 15 of {formatCompactCount(item.total)} rows

{/if} + {#if item.total > 15}

Showing 15 of {formatCompactCount(item.total)} rows

{/if} {/if} {/if}
@@ -1012,7 +1012,7 @@
{item.sql}
- +
@@ -1026,7 +1026,7 @@ {:else}
{item.spec.title || ''} - {item.spec.type} + {item.spec.type}
@@ -1039,8 +1039,8 @@ {#if showWorking}
- - {aiStatusHint || thinkingPhrase} + + {aiStatusHint || thinkingPhrase}
{/if} @@ -1081,7 +1081,7 @@ class={cn('flex w-full items-center gap-2 rounded-md px-2 py-1 text-left transition-colors', active ? 'bg-accent' : 'hover:bg-accent/40')} onmousedown={(e) => { e.preventDefault(); insertMention(item.insert) }} > - + {item.label} {/each} @@ -1099,9 +1099,9 @@ class={cn('flex w-full items-center gap-2 rounded-md px-2 py-1 text-left transition-colors', active ? 'bg-accent' : 'hover:bg-accent/40')} onmousedown={(e) => { e.preventDefault(); runSlash(item) }} > - /{item.cmd} + /{item.cmd} {item.label} - {item.desc} + {item.desc} {/each}
@@ -1114,20 +1114,20 @@ {#if contextTables.length || schemaContext.activeTable || (currentView === 'sql' && currentSql.trim())}
{#each contextTables as t (t)} - - {t} - {/each} {#if schemaContext.activeTable && !contextTables.includes(`${schemaContext.activeSchema}.${schemaContext.activeTable}`)} - - {schemaContext.activeSchema}.{schemaContext.activeTable} + + {schemaContext.activeSchema}.{schemaContext.activeTable} {/if} {#if currentView === 'sql' && currentSql.trim()} - SQL editor + SQL editor {/if}
{/if} @@ -1140,29 +1140,29 @@ rows="1" placeholder={configured ? 'Ask anything, @ tables · / commands' : 'Configure a model first'} disabled={!configured} - class="no-focus-ring max-h-40 min-h-[2.25rem] w-full resize-none bg-transparent px-3 pt-2 pb-1 text-ui-xs leading-relaxed text-foreground outline-none placeholder:text-muted-foreground/40 disabled:opacity-60" + class="no-focus-ring max-h-40 min-h-[2.25rem] w-full resize-none bg-transparent px-3 pt-2 pb-1 text-ui-xs leading-relaxed text-foreground outline-none placeholder:text-muted-foreground disabled:opacity-60" >
{#if loading} {:else} diff --git a/src/lib/components/AppLockScreen.svelte b/src/lib/components/AppLockScreen.svelte index c81d597b..ef3149fa 100644 --- a/src/lib/components/AppLockScreen.svelte +++ b/src/lib/components/AppLockScreen.svelte @@ -97,20 +97,20 @@ aria-label="App PIN" />
-

+

{#if checking}Checking…{:else if wrong}Wrong PIN. Try again.{/if}

·
diff --git a/src/lib/components/AppearanceMenu.svelte b/src/lib/components/AppearanceMenu.svelte index c682464a..e7c52706 100644 --- a/src/lib/components/AppearanceMenu.svelte +++ b/src/lib/components/AppearanceMenu.svelte @@ -9,24 +9,24 @@ import { detectOs } from '$lib/platform.js' const isMac = typeof navigator !== 'undefined' && detectOs() === 'macos' - const mod = isMac ? '⌘' : 'Ctrl' + const mod = isMac ? '⌘' : 'Ctrl' let { - sidebarVisible = true, - tabBarVisible = true, - tableToolbarVisible = true, - statusBarVisible = true, - ontoggleSidebar = () => {}, - ontoggletabbar = () => {}, - ontoggletabletoolbar = () => {}, - ontogglestatusbar = () => {}, - class: extraClass = '', + sidebarVisible = true, + tabBarVisible = true, + tableToolbarVisible = true, + statusBarVisible = true, + ontoggleSidebar = () => {}, + ontoggletabbar = () => {}, + ontoggletabletoolbar = () => {}, + ontogglestatusbar = () => {}, + class: extraClass = '', } = $props() @@ -40,7 +40,7 @@ Sidebar - {#if sidebarVisible}{:else}{/if} + {#if sidebarVisible}{:else}{/if} {mod}B @@ -49,7 +49,7 @@ Tab Bar - {#if tabBarVisible}{:else}{/if} + {#if tabBarVisible}{:else}{/if} {mod}⇧T @@ -58,7 +58,7 @@ Table Toolbar - {#if tableToolbarVisible}{:else}{/if} + {#if tableToolbarVisible}{:else}{/if} @@ -68,7 +68,7 @@ Status Bar - {#if statusBarVisible}{:else}{/if} + {#if statusBarVisible}{:else}{/if} {mod}⇧B diff --git a/src/lib/components/ArrayCellEditor.svelte b/src/lib/components/ArrayCellEditor.svelte index 73cb048f..c030d55c 100644 --- a/src/lib/components/ArrayCellEditor.svelte +++ b/src/lib/components/ArrayCellEditor.svelte @@ -108,16 +108,16 @@

{column}

-

+

{elementType || 'array'}[] - · + · {items.length} {items.length === 1 ? 'element' : 'elements'}

- {i} + {i} {#if item.v === null} @@ -180,17 +180,17 @@ placeholder="value" spellcheck="false" autocomplete="off" - class="h-8 min-w-0 flex-1 rounded-lg border-2 border-border bg-muted/15 px-2.5 font-mono text-ui-xs leading-none text-foreground outline-none transition-[border-color,box-shadow] duration-150 placeholder:text-muted-foreground/30 hover:border-border/60 focus:border-ring/55 focus:ring-2 focus:ring-ring/15" + class= "field-surface h-8 min-w-0 flex-1 bg-muted/15 px-2.5 font-mono text-ui-xs leading-none text-foreground outline-none transition-[border-color,box-shadow] duration-150 placeholder:text-muted-foreground hover:" onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addItem() } }} /> {/if}
@@ -204,7 +204,7 @@
{/if}
@@ -398,7 +398,7 @@ {#if exportPhase === "running"} {:else if exportLogs.length > 0} - {exportLogs.length} entries {/if} @@ -408,7 +408,7 @@ class="min-h-0 flex-1 overflow-y-auto bg-muted/10 p-4 font-mono" > {#if exportLogs.length === 0} -

+

Run an export to see progress here.

{:else} @@ -422,7 +422,7 @@ {/each} {#if exportPhase === "running"}
Running…
@@ -445,7 +445,7 @@ {#if importPhase === "running"} {:else if restoreLogs.length > 0} - {restoreLogs.length} entries {/if} @@ -455,7 +455,7 @@ class="min-h-0 flex-1 overflow-y-auto bg-muted/10 p-4 font-mono" > {#if restoreLogs.length === 0} -

+

{importPhase === "idle" ? "Run a restore to see progress here." : "Waiting for log entries…"} @@ -471,7 +471,7 @@ {/each} {#if importPhase === "running"}

Running…
@@ -617,7 +617,7 @@ > Tables {#if tables.length > 0} - {selectedTables.size}/{tables.length} {/if} @@ -640,7 +640,7 @@
{#if tables.length === 0} -

+

No tables found.

{:else} @@ -660,7 +660,7 @@ {:else} {/if} {#if t.rowCount != null && t.rowCount >= 0} {fmtRows(t.rowCount)} {/if} @@ -787,7 +787,7 @@
- +

@@ -833,13 +833,13 @@

- +

Select a SQL backup file

-

+

Supports .sql and .txt

@@ -946,7 +946,7 @@ >

OK

@@ -957,7 +957,7 @@ {#if importResult.statementsErr > 0}

Failed

@@ -985,7 +985,7 @@ class="mt-2 max-h-36 overflow-y-auto rounded-lg border border-border/40 bg-background p-2.5" > {#each importResult.errors as err, i (i)} -

+

{err}

{/each} @@ -1007,7 +1007,7 @@

Restore failed

-

+

{importError}

diff --git a/src/lib/components/CarbonMeterChart.svelte b/src/lib/components/CarbonMeterChart.svelte index feaaec1e..5556d6cf 100644 --- a/src/lib/components/CarbonMeterChart.svelte +++ b/src/lib/components/CarbonMeterChart.svelte @@ -19,17 +19,17 @@ })) }) - const used = $derived(segments.reduce((s, d) => s + d.value, 0)) - const total = $derived.by(() => { + const used = $derived(segments.reduce((s, d) => s + d.value, 0)) + const total = $derived.by(() => { if (spec?.z_col && spec.data?.[0]?.[spec.z_col] != null) return Math.max(used, Number(spec.data[0][spec.z_col])) return used || 100 }) - const pct = $derived(segments.map(s => ({ ...s, width: total > 0 ? (s.value / total) * 100 : 0 }))) + const pct = $derived(segments.map(s => ({ ...s, width: total > 0 ? (s.value / total) * 100 : 0 }))) const remain = $derived(Math.max(0, total - used)) {#if !segments.length} -
No data
+
No data
{:else}
{#if !noTitle && spec?.title} @@ -38,11 +38,11 @@
- + {used.toLocaleString()} used {#if remain > 0}· {remain.toLocaleString()} available{/if} - {total.toLocaleString()} total + {total.toLocaleString()} total
@@ -61,7 +61,7 @@ {#each segments as seg (seg.name)}
- {seg.name} + {seg.name} {seg.value.toLocaleString()}
{/each} diff --git a/src/lib/components/CellQuickLook.svelte b/src/lib/components/CellQuickLook.svelte index b75a5fb0..6df5e9df 100644 --- a/src/lib/components/CellQuickLook.svelte +++ b/src/lib/components/CellQuickLook.svelte @@ -27,6 +27,12 @@ /** @type {HTMLTextAreaElement | null} */ let textareaEl = $state(null); + /** @type {HTMLElement | null} */ + let panelEl = $state(null); + /** Whatever had focus when the dialog opened, so Escape/Save hands it back + * instead of dropping the caret at the top of the document. */ + let returnFocusEl = /** @type {HTMLElement | null} */ (null); + const titleId = "cellquicklook-title"; // NULL is an explicit state (set via "Set NULL"), distinct from an empty // string. Typing anything clears it so `""` can be saved as a real value. @@ -43,13 +49,46 @@ const hintCls = "font-mono text-ui-3xs tracking-tight opacity-55"; $effect(() => { - if (!cell) return; + if (!cell) { + // Closing: hand focus back to the cell that opened this. + const el = returnFocusEl; + returnFocusEl = null; + if (el?.isConnected) el.focus(); + return; + } + returnFocusEl = /** @type {HTMLElement | null} */ (document.activeElement); void tick().then(() => { textareaEl?.focus(); textareaEl?.select(); }); }); + /** + * Keep Tab inside the panel. Without this, tabbing past Save walks straight + * into the grid behind the backdrop - a keyboard user ends up editing rows + * they cannot see while a modal claims to be in front of them. + * @param {KeyboardEvent} e + */ + function trapTab(e) { + if (e.key !== "Tab" || !panelEl) return; + const focusable = /** @type {HTMLElement[]} */ ( + [...panelEl.querySelectorAll( + 'button:not([disabled]), textarea:not([disabled]), input:not([disabled]), [href], [tabindex]:not([tabindex="-1"])', + )] + ); + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = document.activeElement; + if (e.shiftKey && (active === first || !panelEl.contains(active))) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); + } + } + /** @param {KeyboardEvent} e */ function handleKeydown(e) { if (e.key === "Escape") { @@ -64,6 +103,7 @@ onsave(); return; } + trapTab(e); } function setNull() { @@ -77,7 +117,7 @@
+