Skip to content
Merged
21 changes: 21 additions & 0 deletions .changeset/canvas-render-motion-reconnect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
### Bug Fixes

#### Data Table
- **Grid no longer renders blank** — on macOS WebKit the canvas colour reader returned a stale computed colour, collapsing every theme token to one value so cell text and grid lines vanished (only badges/icons showed). Each colour is now resolved on its own node.
- **Grid no longer blacks out when switching tabs** — the cached 2D context could keep pointing at a canvas WebKit had recreated, so draws landed on an off-screen element. The context now always tracks the live canvas and repaints on mount/tab switch.

#### Schema Visualizer
- **Opens readable instead of a tiny sliver** — a deep schema no longer shrinks to an unreadable horizontal band; it lands at a legible zoom at the top-left of the graph (pan to explore), with roomier, larger table cards and more spacing.

#### Interface
- **Spinners and loaders animate at normal speed** — a global reduced-motion rule was forcing every animation to 0.12s, so spinners/pulses ran frantically fast when macOS "Reduce Motion" was on. Removed the global motion overrides.

### New Features

#### Connection
- **Silent auto-reconnect** — a dropped database connection (network blip, sleep/wake, idle timeout) now reconnects automatically when the network returns, the window refocuses, or you switch tables / refresh, showing only a subtle status-bar indicator instead of a "Connection lost" popup.

### Changes

#### Performance
- **Smoother scrolling and lower memory on large tables** — removed per-frame allocations in the grid render loop, decoupled selection/hover repaints from canvas resizing, and avoid allocating a per-page row-offset array on million-row tables.
29 changes: 0 additions & 29 deletions src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,6 @@
"editor font" and everything else. JetBrains Mono is a true code typeface:
heavier, even rhythm, designed ligatures. */
--editor-font-family: "JetBrains Mono Variable", "Geist Mono Variable", ui-monospace, monospace;
/* Motion tokens — stronger-than-default curves (Emil Kowalski). These override
Tailwind's built-in --ease-out / --ease-in-out so the `ease-out` / `ease-in-out`
utilities (and tw-animate-css enter/exit keyframes, which read --tw-ease) all
inherit the punchier curves. Only transform/opacity should ride these. */
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
}

/* JSON colorizer tokens (highlightJson) — dark-friendly defaults, light override
Expand Down Expand Up @@ -107,28 +100,6 @@ html[data-os="linux"] { --window-radius: 8px; }
@apply border-border outline-ring/50;
}

/* Reduced motion — reduced, not zero (Emil / WCAG). Keep opacity + colour
fades that aid comprehension; strip movement. Neutralising tw-animate-css's
enter/exit transform vars makes overlays fade in place instead of
sliding/zooming, and clamping durations keeps any remaining transition brief. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
--tw-enter-translate-x: 0 !important;
--tw-enter-translate-y: 0 !important;
--tw-exit-translate-x: 0 !important;
--tw-exit-translate-y: 0 !important;
--tw-enter-scale: 1 !important;
--tw-exit-scale: 1 !important;
--tw-enter-rotate: 0 !important;
--tw-exit-rotate: 0 !important;
animation-duration: 0.12s !important;
transition-duration: 0.12s !important;
scroll-behavior: auto !important;
}
}

/* Disable OS word suggestions, autocorrect, and spellcheck on all inputs. */
input:not([type="file"]),
textarea {
Expand Down
63 changes: 57 additions & 6 deletions src/lib/canvas-table.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,51 @@
// once per draw via a hidden probe element. Values are cached by expression for
// the lifetime of the reader (call `createColorReader` again after a theme flip
// — the component recreates it on a theme-change tick).
//
// IMPORTANT: each colour is resolved on a FRESH element rather than by mutating
// one shared probe's `style.color`. macOS WKWebView (Tauri's engine) returns a
// STALE `getComputedStyle(el).color` when `el.style.color` is reassigned to a
// different `var(--…)` between synchronous reads — every lookup after the first
// froze at the first colour's value, so the grid painted every token the same
// colour (text/grid lines vanished, only rgba-literal badges survived). Reading
// each token off a throwaway node sidesteps the stale-recompute bug entirely.

// Neutral mid-grey fallback. Deliberately NOT black: this value can fill the
// entire canvas (the --panel background), so on the rare frame where a token
// fails to resolve, a grey flash is recoverable whereas opaque black is not.
const COLOR_FALLBACK = "rgb(128, 128, 128)";

/** @param {HTMLElement} probe A 0×0 element living inside the table container. */
export function createColorReader(probe) {
/** @type {Map<string, string>} */
const cache = new Map();
// Last successfully-resolved colour, reused if a later read comes back empty
// (e.g. a getComputedStyle against a momentarily-detached subtree) so we never
// paint an unresolved fallback when we already know a good value.
let lastGood = "";
// A scratch 2D context normalises whatever the engine serialises (oklch /
// oklab / rgb / hex) into a canvas-native string (#rrggbb or rgba(...)), so
// downstream alpha math is always reliable.
const scratch = /** @type {HTMLCanvasElement} */ (document.createElement("canvas")).getContext("2d");
return (/** @type {string} */ expr) => {
const hit = cache.get(expr);
if (hit !== undefined) return hit;
probe.style.color = expr;
// Append the throwaway node to a GUARANTEED-connected host — a detached node
// yields an empty computed colour in WebKit, which would poison the fill.
// Theme tokens live on <html>, so <body> inherits them just as the probe does.
const host = probe.isConnected ? (probe.parentNode ?? document.body) : document.body;
const el = document.createElement("span");
el.setAttribute("aria-hidden", "true");
el.className = probe.className;
el.style.cssText = "position:absolute;top:0;left:0;width:0;height:0;overflow:hidden;pointer-events:none";
el.style.color = expr;
host.appendChild(el);
// getComputedStyle resolves var()/oklch/hsl to a concrete computed colour.
let resolved = getComputedStyle(probe).color || "rgb(0,0,0)";
let resolved = getComputedStyle(el).color;
el.remove();
// Unresolved (empty) → reuse the last good colour, else a neutral grey. Do
// NOT cache this: a later read (once the DOM settles) should resolve properly.
if (!resolved) return lastGood || COLOR_FALLBACK;
if (scratch) {
try {
scratch.fillStyle = "#010203"; // sentinel
Expand All @@ -42,6 +72,7 @@ export function createColorReader(probe) {
/* keep the computed string */
}
}
lastGood = resolved;
cache.set(expr, resolved);
return resolved;
};
Expand Down Expand Up @@ -350,11 +381,19 @@ export function resizeColAtX(x, geom, scrollLeft, slop = 5, frozenLeft = geom.gu
* Cumulative top offset of every row. Fixed `rowHeight` rows, plus expand height
* for each expanded row. Per-row measured heights are used when available;
* `defaultExpandHeight` is the fallback before measurement.
*
* Returns `null` in the common case where NO row is expanded — every row top is
* then exactly `idx * rowHeight`, so the O(1) linear path in the consumers
* (rowDocTop / rowIndexAtY / rowAtContentY) applies and we skip allocating a
* Float64Array over all rows. On a 1M-row result set that array is ~8 MB and was
* being reallocated on every infinite-scroll page append — this avoids it
* entirely unless the user actually expands a row.
* @param {Set<number>} expandedSet
* @param {Map<number,number> | null} expandHeights Per-row measured heights (optional).
* @returns {Float64Array} length rowCount+1; [rowCount] is the total height.
* @returns {Float64Array | null} length rowCount+1 ([rowCount] = total height), or null when unexpanded.
*/
export function computeRowTops(rowCount, expandedSet, defaultExpandHeight, rowHeight, expandHeights = null) {
if (expandedSet.size === 0) return null;
const tops = new Float64Array(rowCount + 1);
let y = 0;
for (let i = 0; i < rowCount; i++) {
Expand All @@ -365,9 +404,14 @@ export function computeRowTops(rowCount, expandedSet, defaultExpandHeight, rowHe
return tops;
}

/** Largest row index with top ≤ y (binary search). */
export function rowIndexAtY(tops, rowCount, y) {
/** Largest row index with top ≤ y (binary search; O(1) linear when `tops` is null). */
export function rowIndexAtY(tops, rowCount, y, rowHeight = 0) {
if (rowCount === 0 || y < 0) return 0;
if (tops === null) {
// Uniform rows: top(idx) = idx * rowHeight.
if (rowHeight <= 0) return 0;
return Math.min(rowCount - 1, Math.max(0, Math.floor(y / rowHeight)));
}
let lo = 0, hi = rowCount - 1, idx = 0;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
Expand All @@ -382,7 +426,14 @@ export function rowIndexAtY(tops, rowCount, y) {
* @returns {{ idx: number, inRowBody: boolean } | null}
*/
export function rowAtContentY(tops, rowCount, rowHeight, y) {
if (rowCount === 0 || y < 0 || y >= tops[rowCount]) return null;
if (rowCount === 0 || y < 0) return null;
if (tops === null) {
// Uniform rows: total height = rowCount * rowHeight, top(idx) = idx*rowHeight.
if (y >= rowCount * rowHeight) return null;
const idx = Math.min(rowCount - 1, Math.floor(y / rowHeight));
return { idx, inRowBody: y < idx * rowHeight + rowHeight };
}
if (y >= tops[rowCount]) return null;
const idx = rowIndexAtY(tops, rowCount, y);
return { idx, inRowBody: y < tops[idx] + rowHeight };
}
Loading
Loading