From f8378a6e63266fc2a1a6b89c2bd842bf46585dd5 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Wed, 23 Sep 2026 10:23:30 +0545 Subject: [PATCH 01/29] Read the clipboard only when the paste bar is clicked The connect dialog read the clipboard the moment it opened and again on every window focus and visibility change. On macOS each read can raise the system paste prompt, so simply opening the dialog, or switching back to the app, asked for permission the user never meant to give. The read now happens on a click into the paste bar. Focus alone does nothing, so the bar still takes focus on open and Cmd+V then Enter works as before. Typed text still wins and a string that was cleared stays cleared; the read is skipped entirely when the bar already holds text. --- src/lib/components/ConnectionModal.svelte | 37 ++++++++--------------- 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/src/lib/components/ConnectionModal.svelte b/src/lib/components/ConnectionModal.svelte index 36527229..d8175bb2 100644 --- a/src/lib/components/ConnectionModal.svelte +++ b/src/lib/components/ConnectionModal.svelte @@ -528,36 +528,25 @@ /** * A connection string in the clipboard is where this dialog usually starts - * copied out of a provider dashboard, a .env, or a teammate's message - and - * the first thing anyone does here is paste it. So the dialog pastes it. + * the first thing anyone does here is paste it. So clicking into the paste + * bar pastes it. * - * Strictly: only a string that parses as a connection URI, only into an empty - * bar, and nothing is applied until Continue is pressed. It saves the paste, + * Strictly: only on a click into the bar (never on open or window focus - on + * macOS every clipboard read can raise the system paste prompt), only a string + * that parses as a connection URI, only into an empty bar, and nothing is + * applied until Continue is pressed. It saves the paste, * not the decision. A `.env` line is unwrapped (`DATABASE_URL="postgres://…"`) * because that is the form the string is usually copied in. */ /** What the clipboard last put in the bar, so a refill can tell its own text from typing. */ let clipboardFilled = ""; - /** A string the user cleared away. Putting it back on the next focus would be a fight. */ + /** A string the user cleared away. Putting it back on the next click would be a fight. */ let clipboardDismissed = ""; - // Re-read on every window focus, not only on open: the usual shape of this is - // copying the string from a provider dashboard in the browser and coming - // back, and by then the dialog has been open for a minute. Both events are - // bound - a Tauri window focus fires `focus` on the webview, and a workspace - // switch or an unminimise only fires `visibilitychange`. - $effect(() => { - if (!open) return; - const reread = () => { if (step === "pick") void prefillFromClipboard(); }; - const onVisible = () => { if (!document.hidden) reread(); }; - window.addEventListener("focus", reread); - document.addEventListener("visibilitychange", onVisible); - return () => { - window.removeEventListener("focus", reread); - document.removeEventListener("visibilitychange", onVisible); - }; - }); - async function prefillFromClipboard() { + // Typing beats the clipboard, always - and then there is no reason to read it. + if (!open || step !== "pick") return; + if (quickUri.trim() && quickUri !== clipboardFilled) return; try { const raw = String((await navigator.clipboard.readText()) ?? "").trim(); if (!raw || raw.length > 2000 || raw.includes("\n")) return; @@ -567,8 +556,7 @@ .trim(); const candidate = detectConnectionUri(raw) ? raw : detectConnectionUri(unwrapped) ? unwrapped : ""; if (!candidate) return; - // Anything the user did in the meantime wins - this lands a tick or two - // after the dialog opened, and re-runs every time the window is focused. + // Anything the user did while the read was in flight wins. if (!open || step !== "pick") return; if (candidate === quickUri) return; // already there if (candidate === clipboardDismissed) return; // they cleared this one away @@ -1472,8 +1460,8 @@ quickUri = ""; quickHint = ""; void refreshLocal(); - void prefillFromClipboard(); // The paste bar takes focus: the modal opens, you paste, you press Enter. + // Focus alone does not read the clipboard - only a click into the bar does. // Only on the front page - a saved connection opens straight into its form. void tick().then(() => { if (step === "pick") quickUriEl?.focus(); }); }); @@ -3211,6 +3199,7 @@ aria-label="Paste a connection string" spellcheck="false" class="h-9 pl-8 font-mono text-ui-xs" + onclick={() => void prefillFromClipboard()} oninput={(e) => { quickHint = ""; // Cleared on purpose: do not hand the same string From 8aba95b8d61dc499423cd36875c295a8dae00bac Mon Sep 17 00:00:00 2001 From: broisnischal Date: Wed, 23 Sep 2026 10:23:43 +0545 Subject: [PATCH 02/29] Fade the window in once, on its first complete screen App mount faded the page in while AppLockGate was still reading the keychain (up to 1.5s), so what faded in was an empty page. The shell then popped in unfaded on top of it, and the reconnect overlay after that. The page now stays at opacity 0 until the first screen that is actually finished: the shell once it has decided between onboarding, the connect dialog, the welcome screen and the reconnect overlay, or the lock screen, the trial screen or the crash screen. A 2.5s failsafe reveals it anyway, so the window can never be left blank. --- src/App.svelte | 6 ++--- src/lib/app-reveal.js | 26 ++++++++++++++++++++ src/lib/components/AppErrorBoundary.svelte | 3 ++- src/lib/components/AppLockScreen.svelte | 2 ++ src/lib/components/StudioShell.svelte | 6 +++++ src/lib/components/TrialExpiredScreen.svelte | 2 ++ src/main.js | 2 ++ 7 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 src/lib/app-reveal.js diff --git a/src/App.svelte b/src/App.svelte index bfbbac4a..d2178f04 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -69,10 +69,8 @@ if ((e.ctrlKey || e.metaKey) && e.key === 'p') { e.preventDefault(); e.stopPropagation() } }, { capture: true }) - // Fade in the page now that theme + layout are ready. The window uses native - // OS decorations and opens maximized via the Tauri builder, so the OS owns - // all window state - no JS show/maximize/geometry handling needed here. - document.documentElement.style.opacity = '1' + // No fade-in here: AppLockGate has not rendered anything yet. The first + // complete screen reveals the page - see $lib/app-reveal.js. }) diff --git a/src/lib/app-reveal.js b/src/lib/app-reveal.js new file mode 100644 index 00000000..9ac85bad --- /dev/null +++ b/src/lib/app-reveal.js @@ -0,0 +1,26 @@ +// The page boots at opacity 0 (index.html) and is faded in exactly once, by the +// first screen that is actually complete: the shell after its startup decision +// (reconnect overlay, welcome, onboarding), the lock screen, the trial screen, +// or the crash screen. Revealing earlier - on App mount - faded in an empty page +// while AppLockGate was still reading the keychain, and the shell then popped in +// unfaded on top of it. + +const FAILSAFE_MS = 2500 + +let revealed = false + +/** Fade the page in. Idempotent - only the first caller does anything. */ +export function revealApp() { + if (revealed || typeof document === 'undefined') return + revealed = true + // One frame so whatever the caller just set (overlay, modal) is in the DOM + // before the fade starts. + requestAnimationFrame(() => { + document.documentElement.style.opacity = '1' + }) +} + +/** Never leave a blank window: reveal regardless if no screen claimed it. */ +export function armRevealFailsafe() { + setTimeout(revealApp, FAILSAFE_MS) +} diff --git a/src/lib/components/AppErrorBoundary.svelte b/src/lib/components/AppErrorBoundary.svelte index 2821d747..31aa91a9 100644 --- a/src/lib/components/AppErrorBoundary.svelte +++ b/src/lib/components/AppErrorBoundary.svelte @@ -4,6 +4,7 @@ import Bug from '@lucide/svelte/icons/bug' // Static: StudioShell already imports it, so it is in the main chunk either way. import ReportIssueDialog from './ReportIssueDialog.svelte' + import { revealApp } from '$lib/app-reveal.js' let { children } = $props() @@ -25,7 +26,7 @@ } - { try { console.error('[app-boundary]', e) } catch { /* noop */ } }}> + { try { console.error('[app-boundary]', e) } catch { /* noop */ } revealApp() }}> {@render children()} {#snippet failed(error, reset)} diff --git a/src/lib/components/AppLockScreen.svelte b/src/lib/components/AppLockScreen.svelte index ef3149fa..434a084a 100644 --- a/src/lib/components/AppLockScreen.svelte +++ b/src/lib/components/AppLockScreen.svelte @@ -3,6 +3,7 @@ // cold start the app is not mounted behind it at all (see AppLockGate); after // an idle auto-lock the session is still there, covered and key-deaf. import { onMount } from 'svelte' + import { revealApp } from '$lib/app-reveal.js' import Logo from './Logo.svelte' import Icon from './Icon.svelte' import PinField from './PinField.svelte' @@ -20,6 +21,7 @@ let isTauri = $state(false) onMount(() => { isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window + revealApp() }) /** @param {MouseEvent} e */ diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index ae7da98d..98b21120 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -1,6 +1,7 @@ -
+ +
-
-
-
engine
-
{dbType}
-
- {#if schemas.length > 0 && activeSchema} -
-
schema
-
{activeSchema}
-
- {/if} -
-
tables
-
{tables.length.toLocaleString('en-US')}
-
- {#if connection.host} -
-
host
-
{connection.host}{connection.port ? `:${connection.port}` : ''}
-
- {:else if connection.filePath} -
-
file
-
{connection.filePath}
-
- {/if} - {#if connection.user} -
-
user
-
{connection.user}
-
- {/if} -
-
- {/if}
+ {sourceHint} {/if} {#if isNull && !dirty} NULL @@ -635,69 +479,15 @@ edited {/if} -
- - -
-
- {#if isTreeable} - - - {/if} + +
+ {/if} +
+ {/if}
- - {#if !wrap && rawOpen} - - {/if} - - {#if rawOpen && rawRuns} - - {/if} {#if heavy && rawOpen} - - {#if parsed} - -
-
- - {parsed.ok ? 'Tree' : 'Preview'} - - {#if parsed.truncated} - truncated value - {:else if !parsed.ok} - invalid JSON - {/if} -
-
- {#if parsed.ok && query && !treeSearch?.count} -

- No match for {query} in this value. -

- {:else if parsed.ok} - { - const text = typeof v === 'string' ? v : JSON.stringify(v, null, 2) - void navigator.clipboard?.writeText(text) - toast.success('Copied') - }} - /> - {:else} -

{parsed.error}

- {#if parsed.heavy} - -

- The raw text is already here, and Find works on it under 2 MB. -

- {/if} - {#if parsed.truncated && oversize && onloadfull} - -

- Fetched for this row only - the page stays light. -

- {/if} - {/if} -
-
- {/if} +
+ + {#await import('./CodeEditor.svelte') then { default: CodeEditor }} + + {/await} +
From 56eef068b7ff4da8341fd6763aa7e2b6de09cba9 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 24 Sep 2026 15:39:17 +0545 Subject: [PATCH 11/29] Leave Mod+F and Mod+H to the code editor when it has focus Inside the cell panel's CodeMirror (its find bar included), Mod+F is the editor's own find and Mod+H / Mod+Alt+F its replace. The shell's hotkeys took those keys first and pulled focus up to the table's row search or the find-and-replace panel, and the sidebar's Mod+F handler moved it to the table filter. All three now step aside for a target inside .cm-editor, and the sidebar also respects a key the editor already marked handled. --- src/lib/components/Sidebar.svelte | 3 +++ src/lib/components/StudioShell.svelte | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 912dcc78..8b5b9e41 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -1153,6 +1153,9 @@ const isFilterKey = (e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key === 'f' const isEscClear = e.key === 'Escape' && selectedItems.size > 0 if (!isFilterKey && !isEscClear) return + // A code editor handles its own Mod-F (find and replace) and marks the key + // handled; stealing it here sent focus to the sidebar filter instead. + if (e.defaultPrevented || (e.target instanceof Element && e.target.closest('.cm-editor'))) return // Guard: filterEl.offsetParent is null when sidebar is hidden via display:none if (!filterEl || !filterEl.offsetParent) return if (isFilterKey) { diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index e4cb5850..dfe0d7f6 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -2262,6 +2262,10 @@ let rowSearch = $state('') createHotkey('Mod+F', (e) => { if (commandOpen || showConnectionModal || showSettingsModal) return + // Inside a code editor (the cell panel's CodeMirror, its find bar + // included) Mod+F is the editor's own find and replace. Taking it here + // pulled focus up to the table's row search instead. + if (e.target instanceof Element && e.target.closest('.cm-editor')) return // Find means "search what this page is showing", and on the objects page // that is its own box. It used to mean nothing there at all. if (activeTab?.kind === 'objects') { e.preventDefault(); objectsFocusSearch?.(); return } @@ -2523,6 +2527,8 @@ let rowSearch = $state('') // Find & replace in the current table - editor-style Ctrl/⌘+H. createHotkey('Mod+H', (e) => { + // In a code editor this is the editor's own replace, not the table's. + if (e.target instanceof Element && e.target.closest('.cm-editor')) return if (!connection || !activeTable || columns.length === 0 || !findReplaceEnabled) return e.preventDefault() openFindReplacePanel() @@ -2532,6 +2538,8 @@ let rowSearch = $state('') // swallows Cmd+H to hide the app, so Mod+H never reaches us there - this is the // reliable cross-platform binding. createHotkey('Mod+Alt+F', (e) => { + // In a code editor this is the editor's own replace, not the table's. + if (e.target instanceof Element && e.target.closest('.cm-editor')) return if (!connection || !activeTable || columns.length === 0 || !findReplaceEnabled) return e.preventDefault() openFindReplacePanel() From 46cdd85603596c981d9dfc845b1741b3ccab2ba4 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 24 Sep 2026 15:39:17 +0545 Subject: [PATCH 12/29] Give the code editor's find fields the standard input shortcuts inManagedEditor() matched anything under .cm-editor, which includes the inputs in CodeMirror's search panel, so those fields got no word delete, no undo and no shield from the global hotkeys. CodeMirror manages only its text surface, .cm-content, and that is now the element that counts. --- src/lib/input-shortcuts.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/input-shortcuts.js b/src/lib/input-shortcuts.js index cb777a86..a1e9cc15 100644 --- a/src/lib/input-shortcuts.js +++ b/src/lib/input-shortcuts.js @@ -52,7 +52,10 @@ function editableKind(/** @type {Element} */ el) { } function inManagedEditor(/** @type {Element} */ el) { - return !!el.closest?.('.monaco-editor, .cm-editor') + // CodeMirror manages its text surface (.cm-content), not the inputs in its + // panels: matching all of .cm-editor left the find/replace fields with no + // word delete, no undo and no shield from the global hotkeys. + return !!el.closest?.('.monaco-editor, .cm-content') } /** @param {HTMLInputElement | HTMLTextAreaElement} el */ From c812de22c1211ba10dbe0212715edfac7b18aeb0 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 24 Sep 2026 15:39:28 +0545 Subject: [PATCH 13/29] Walk the sidebar table list with Shift+Tab on Linux On WebKitGTK, Shift+Tab reaches the page as GTK's ISO_Left_Tab keysym, not "Tab", so the key-name check skipped the row handler and the browser's own Shift+Tab jumped to the filter instead of the previous row. The check now accepts the physical key (e.code) too. Row navigation also found the current row through document.activeElement. The row's checkbox span is tabindex="-1", so a click near the icon focused the span, which is in no row list, and every Tab or arrow from there fell through. It now uses the row the event came from. --- src/lib/components/Sidebar.svelte | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 8b5b9e41..8fdd57bd 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -1612,11 +1612,20 @@ // a list you cannot tab out of is a focus trap - and Shift+Tab off // the first returns to the filter the list was narrowed from. // Shift+Enter (above) is the deliberate way into the grid. - if (e.key === 'Tab') { + // `e.code` as well as `e.key`: on WebKitGTK, Shift+Tab reaches the + // page as GTK's ISO_Left_Tab keysym rather than "Tab", so a key-name + // check skipped this branch and the browser's own Shift+Tab moved + // focus to the previous tab stop - the filter - instead of the + // previous row. The physical key is Tab on every engine. + if (e.key === 'Tab' || e.code === 'Tab') { const onRow = e.target instanceof Element && e.target.closest('[data-sidebar-row]') if (!onRow) return const rows = listRowButtons() - const i = rows.indexOf(/** @type {HTMLElement} */ (document.activeElement)) + // The ROW, not document.activeElement: the row's checkbox span is + // tabindex="-1", so a click near the icon focuses the span, which + // is in no row list - every Shift+Tab from there fell through to + // the filter instead of going to the previous table. + const i = rows.indexOf(/** @type {HTMLElement} */ (onRow)) const next = i === -1 ? null : rows[i + (e.shiftKey ? -1 : 1)] if (next) { e.preventDefault(); kbdNav = true; next.focus() @@ -1635,7 +1644,8 @@ // box - the field the list was narrowed from. if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Home' && e.key !== 'End') return const rows = listRowButtons() - const i = rows.indexOf(/** @type {HTMLElement} */ (document.activeElement)) + const focusedRow = e.target instanceof Element ? e.target.closest('[data-sidebar-row]') : null + const i = rows.indexOf(/** @type {HTMLElement} */ (focusedRow)) if (i === -1) return e.preventDefault() kbdNav = true From 93112556401f9b4b24152acb943340eb7bcad0fd Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 24 Sep 2026 15:39:28 +0545 Subject: [PATCH 14/29] Match Shift+Tab by physical key in the database and connection lists Same WebKitGTK ISO_Left_Tab keysym as the sidebar: Shift+Tab in the status bar's database picker and in the saved-connection list compared e.key only, so on Linux it moved focus out of the list instead of up it. Both now accept e.code === "Tab" as well. --- src/lib/components/ConnectionModal.svelte | 2 +- src/lib/components/StatusBar.svelte | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/components/ConnectionModal.svelte b/src/lib/components/ConnectionModal.svelte index a080ef70..4104947c 100644 --- a/src/lib/components/ConnectionModal.svelte +++ b/src/lib/components/ConnectionModal.svelte @@ -2906,7 +2906,7 @@ } // Shift+Tab is the way back to the filter, the mirror of // the Tab that got here. - if (e.key === "Tab" && e.shiftKey && savedSearchEl) { + if ((e.key === "Tab" || e.code === "Tab") && e.shiftKey && savedSearchEl) { e.preventDefault(); savedSearchEl.focus(); return; diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index 65f7cf93..46f154aa 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -154,7 +154,7 @@ e.preventDefault(); e.stopPropagation() dbHl = (dbHl + 1) % n scrollDbHlIntoView() - } else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) { + } else if (e.key === 'ArrowUp' || ((e.key === 'Tab' || e.code === 'Tab') && e.shiftKey)) { e.preventDefault(); e.stopPropagation() dbHl = (dbHl - 1 + n) % n scrollDbHlIntoView() From c405ce97413a1cace7b8d674535c2657c91b5da3 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 24 Sep 2026 15:39:28 +0545 Subject: [PATCH 15/29] Show the connection row focus ring for keyboard focus only Saved-connection rows styled :focus, so clicking a row left the focus outline on it as if it had been reached with Tab. The row now uses :focus-visible. While that outline is up, the row's selection bar fades out: the outline is drawn 2px inside the row, so the bar sat right against its edge and read as a doubled border. --- src/lib/components/ConnectionModal.svelte | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/lib/components/ConnectionModal.svelte b/src/lib/components/ConnectionModal.svelte index 4104947c..a1b2809b 100644 --- a/src/lib/components/ConnectionModal.svelte +++ b/src/lib/components/ConnectionModal.svelte @@ -2868,7 +2868,7 @@
{#if isSel} + {/if} From 18eee06adedcb2613519e3a1f5c55e94b300010b Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 24 Sep 2026 15:39:36 +0545 Subject: [PATCH 16/29] Keep Hide all reachable in the columns menu The columns menu had one toggle that read "Show all" whenever anything was hidden. Incoming foreign keys load after the table opens, so hiding everything before they landed left the relationship columns visible with something already hidden, and the button flipped to "Show all" with no way left to hide them. The menu now offers each action whenever it means something: Hide all while any real or relationship column is still visible, Show all while any is hidden, both at once in between. --- src/lib/components/TableToolbar.svelte | 61 ++++++++++++++++++-------- 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/src/lib/components/TableToolbar.svelte b/src/lib/components/TableToolbar.svelte index 3ace9314..e0e3a59f 100644 --- a/src/lib/components/TableToolbar.svelte +++ b/src/lib/components/TableToolbar.svelte @@ -312,6 +312,24 @@ const hiddenCount = $derived(hiddenColumns.size); + /** Everything the Hide all / Show all control owns: real columns and the + * virtual relationship columns. Virtual expr columns are left out - they are + * enabled/disabled through their own store, not through `hiddenColumns`. */ + const hideableColumnValues = $derived([ + ...columns.map((c) => c.name), + ...virtualRelColumns.map((vc) => `__vrel:${vc.label}`), + ]); + + /* The control asks "is anything still visible?", not "is anything hidden?". + Incoming foreign keys load after the table opens, so hiding everything + before they land leaves the relationship columns visible with something + already hidden - and keying the label off `hiddenCount` turned the button + into "Show all" at exactly that point, with no way left to hide them. */ + const allColumnsHidden = $derived( + hideableColumnValues.length > 0 && + hideableColumnValues.every((v) => hiddenColumns.has(v)), + ); + /** @param {string} name */ function toggleColumn(name) { const next = new Set(hiddenColumns); @@ -530,17 +548,8 @@ } } - function toggleAllColumns() { - if (hiddenCount > 0) { - showAllColumns(); - } else { - onhiddencolumnschange( - new Set([ - ...columns.map((c) => c.name), - ...virtualRelColumns.map((vc) => `__vrel:${vc.label}`), - ]), - ); - } + function hideAllColumns() { + onhiddencolumnschange(new Set(hideableColumnValues)); } /** Filter-row column options: "Any column" + every column. */ @@ -1048,13 +1057,29 @@ {#snippet header()}
Columns - + +
+ {#if !allColumnsHidden} + + {/if} + {#if hiddenCount > 0} + + {/if} +
{/snippet} {#snippet item(it)} From a2693cbaa9b64b7530b991dc5452835fe83a2cee Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 24 Sep 2026 15:39:36 +0545 Subject: [PATCH 17/29] Keep the related rows on screen while the next lookup loads Every lookup replaced the panel's data with an empty loading result first, so clicking another row's key blanked the table, headers and all, until the result landed. The panel now draws the last settled result until the next one arrives and dims it once the load has run long enough to show a spinner, so a lookup reads as one swap. The held result is only reused for the same relationship; another one would be the wrong table. --- src/lib/components/FkSubviewPanel.svelte | 50 ++++++++++++++++-------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/src/lib/components/FkSubviewPanel.svelte b/src/lib/components/FkSubviewPanel.svelte index 265c039b..89930cc2 100644 --- a/src/lib/components/FkSubviewPanel.svelte +++ b/src/lib/components/FkSubviewPanel.svelte @@ -38,6 +38,24 @@ onfullview = () => {}, } = $props() + /** + * What the panel draws. Every lookup replaces `data` with an empty + * `{ loading: true, rows: [] }` first, so drawing `data` directly blanked the + * table - headers and all - for the frames between clicking another row's key + * and its result landing. Holding the last settled result until the next one + * arrives turns that into a single swap. It is only reused for the same + * relationship: another relationship's columns would be the wrong table. + */ + let settled = $state.raw(/** @type {any} */ (null)) + let settledLabel = $state('') + $effect(() => { + if (data && !data.loading) { + settled = data + settledLabel = fkLabel + } + }) + const view = $derived(data?.loading && settled && settledLabel === fkLabel ? settled : data) + /** `data.loading`, held back ~220ms (the grid's SPAN_SHOW_AFTER) so it never flashes. */ let loadingVisible = $state(false) $effect(() => { @@ -53,8 +71,8 @@ return String(v) } - const rowCount = $derived(data?.rows?.length ?? 0) - const colNames = $derived((data?.columns ?? []).map((c) => c.name ?? c)) + const rowCount = $derived(view?.rows?.length ?? 0) + const colNames = $derived((view?.columns ?? []).map((c) => c.name ?? c)) /** * Column widths and alignment, from the same two helpers the grid above sizes @@ -65,7 +83,7 @@ * the grid's. */ const cols = $derived( - (data?.columns ?? []).map((c) => { + (view?.columns ?? []).map((c) => { const type = c.dataType ?? c.data_type ?? '' return { name: c.name ?? c, @@ -93,13 +111,13 @@ // A new result set invalidates any cell coordinate held from the previous one. $effect(() => { - void data + void view sel = null }) /** @param {number} i row index @param {number} j column index */ function cellAt(i, j) { - const row = data?.rows?.[i] + const row = view?.rows?.[i] if (row === undefined) return undefined return Array.isArray(row) ? row[j] : row[colNames[j]] } @@ -115,7 +133,7 @@ /** Tab-separated, which is what spreadsheets and editors paste as columns. */ function allTsv() { const header = colNames.join('\t') - const body = data.rows.map((_, i) => colNames.map((_, j) => fmt(cellAt(i, j))).join('\t')) + const body = view.rows.map((_, i) => colNames.map((_, j) => fmt(cellAt(i, j))).join('\t')) return [header, ...body].join('\n') } @@ -180,7 +198,7 @@ {#if sourceHint} ({sourceHint}) {/if} - {#if !data?.loading && !data?.error} + {#if !view?.loading && !view?.error} {rowCount}{rowCount >= 50 ? '+' : ''} row{rowCount !== 1 ? 's' : ''} @@ -219,7 +237,7 @@
- {#if data?.loading} + {#if view?.loading}
@@ -231,12 +249,12 @@ {/if}
- {:else if data?.error} + {:else if view?.error}
Couldn't load related rows
-
{data.error}
+
{view.error}
@@ -249,7 +267,7 @@ {:else} -
+
{#snippet child({ props })} @@ -303,7 +321,7 @@ - {#each data.rows as row, i (i)} + {#each view.rows as row, i (i)} {i + 1} @@ -335,7 +353,7 @@ aria-selected={isSel} class={cn( 'cursor-default overflow-hidden align-middle text-ellipsis whitespace-nowrap outline-none', - metrics.rowRules && i < data.rows.length - 1 && 'border-b border-border/15', + metrics.rowRules && i < view.rows.length - 1 && 'border-b border-border/15', metrics.colRules && 'border-r border-r-border/15', c.alignRight && 'text-right tabular-nums', isNullVal && 'italic text-muted-foreground/70', @@ -375,7 +393,7 @@ sel && copy(data.rows.map((_, i) => fmt(cellAt(i, sel.c))).join('\n'), `Column ${colNames[sel.c]}`)} + onSelect={() => sel && copy(view.rows.map((_, i) => fmt(cellAt(i, sel.c))).join('\n'), `Column ${colNames[sel.c]}`)} > Copy column @@ -385,7 +403,7 @@ Copy all rows - copy(JSON.stringify(data.rows.map((_, i) => rowObject(i)), null, 2), 'All rows JSON')}> + copy(JSON.stringify(view.rows.map((_, i) => rowObject(i)), null, 2), 'All rows JSON')}> Copy all as JSON From 9f4a4f6a561c638482b81a71fbfefd18f3bf4404 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 24 Sep 2026 15:39:46 +0545 Subject: [PATCH 18/29] Steady the license activation form and its celebration - The confetti draws on a canvas the form owns rather than the one canvas-confetti appends to , which outlived the screen: the app mounted underneath and the last particles fell across the workspace. - The confetti chunk is fetched when the key field is first touched, not on success, so no chunk load sits between "Activated" and the first particle. - "Verifying" only shows once a check has taken 200ms; a local check answers in a frame and the label swap was its own flicker. - A previous error stays up until the next result replaces it, instead of collapsing the line and jumping the button on every attempt. - New onsuccess (key accepted) and onactivated (celebration over) callbacks let the caller move on at the right moment, between 1.2s and 3.2s after success. - The field and button use the shared Input and Button components. --- src/lib/components/LicenseActivation.svelte | 194 ++++++++++++++------ 1 file changed, 140 insertions(+), 54 deletions(-) diff --git a/src/lib/components/LicenseActivation.svelte b/src/lib/components/LicenseActivation.svelte index 1d0c7f7e..582c68a5 100644 --- a/src/lib/components/LicenseActivation.svelte +++ b/src/lib/components/LicenseActivation.svelte @@ -1,24 +1,63 @@ {#snippet keyField(extraClass)} +
- - + { error = ''; void loadConfetti() }} + aria-label="License key" + aria-invalid={error ? 'true' : undefined} + aria-describedby={error ? 'license-key-error' : undefined} placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" spellcheck="false" autocomplete="off" - disabled={loading || success} - class="h-11 w-full rounded-lg border-2 border-border bg-muted/20 pl-10 pr-3 font-mono text-ui-sm tracking-wider text-foreground outline-none transition-[color,border-color] placeholder:font-sans placeholder:tracking-normal placeholder:text-muted-foreground hover:border-foreground/25 focus:border-ring focus-visible:ring-0 disabled:opacity-50" + readonly={loading} + disabled={success} + class="bg-muted/20 pl-8 font-mono tracking-wide placeholder:text-muted-foreground/45" />
{/snippet} {#snippet submitBtn(fullWidth, label)} - + {/snippet} -
+
{#if !compact && !naked && !inline}

Enter your license key

@@ -111,23 +190,30 @@ {@render keyField('')} {/if} - - {#if error} -
- - {error} -
- {/if} - - - {#if success} -
- - License activated, welcome aboard! + +
+
+

+ + {shownError} +

- {/if} +
{#if !inline} {@render submitBtn(true, 'Activate License')} {/if}
+ +{#if success} + +{/if} From b8d40c8e37f50986cba49866df9c4b00eccab95a Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 24 Sep 2026 15:39:47 +0545 Subject: [PATCH 19/29] Hold the trial screen until activation has finished celebrating activateLicense() refreshes the license status before it resolves, so the gate saw "not blocked" mid-activation and mounted the whole app in the same tick the confetti started. That made the celebration stutter and hid the "Activated" state it was for. LicenseGate now holds the trial screen once it has shown, until the screen reports it is done. TrialExpiredScreen turns its copy into a welcome when the key is accepted, fades out when the celebration ends, and then hands over. The copy is also two short lines (what to do, then the reassurance) instead of one sentence balanced into three. --- src/lib/components/LicenseGate.svelte | 14 ++++++- src/lib/components/TrialExpiredScreen.svelte | 42 +++++++++++++++----- 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/src/lib/components/LicenseGate.svelte b/src/lib/components/LicenseGate.svelte index e6ae9bc7..3d7ca68d 100644 --- a/src/lib/components/LicenseGate.svelte +++ b/src/lib/components/LicenseGate.svelte @@ -11,11 +11,21 @@ await refreshLicenseStatus() runLicenseCheck() }) + + // Once the gate has shown, it stays up until the screen says it is done. + // activateLicense() refreshes the status before it resolves, so $isBlocked + // goes false mid-activation - and dropping the gate right then mounted the + // whole app in the same tick the confetti started, which is what made the + // celebration stutter (and hid the "Activated" state it was celebrating). + let holdGate = $state(false) + $effect(() => { + if ($isBlocked) holdGate = true + }) -{#if $isBlocked} +{#if $isBlocked || holdGate} - + (holdGate = false)} /> {:else} {@render children()} {/if} diff --git a/src/lib/components/TrialExpiredScreen.svelte b/src/lib/components/TrialExpiredScreen.svelte index 8865cf22..7382a002 100644 --- a/src/lib/components/TrialExpiredScreen.svelte +++ b/src/lib/components/TrialExpiredScreen.svelte @@ -9,6 +9,19 @@ import LicenseActivation from './LicenseActivation.svelte' import WindowControls from './WindowControls.svelte' import { detectOs } from '$lib/platform.js' + import { cn } from '$lib/utils.js' + + /** Called once activation has finished celebrating; LicenseGate drops the gate on it. */ + let { onactivated = () => {} } = $props() + + /** The key was accepted: the copy turns into a welcome while the confetti runs. */ + let activated = $state(false) + /** The celebration is over: fade out, then let the gate hand over to the app. */ + let leaving = $state(false) + function finish() { + leaving = true + setTimeout(onactivated, 220) + } const isMac = typeof navigator !== 'undefined' && detectOs() === 'macos' @@ -38,7 +51,7 @@ } -
+
-
- +
+
-
-

Your free trial has ended

-

- Activate a license to keep using Stroke. Your saved connections and settings are untouched, they'll be right here. -

+
+ + {#if activated} +

Welcome to Stroke

+

Your license is active.

+

Opening your workspace.

+ {:else} +

Your free trial has ended

+

Activate a license to keep using Stroke.

+

Your connections and settings are saved and waiting.

+ {/if}
- + (activated = true)} onactivated={finish} />
-
+
- Get a license + Get a license ·