Skip to content

Stroke 2.0 - #86

Merged
broisnischal merged 111 commits into
masterfrom
feat/polish-ui
Sep 22, 2026
Merged

broisnischal merged 111 commits into
masterfrom
feat/polish-ui

Conversation

@broisnischal

@broisnischal broisnischal commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Merging this with the release:major label cuts v2.0.0. The workflow bumps tauri.conf.json and package.json from 1.24.0, folds .changeset/wide-columns-and-staged-rows.md into CHANGELOG.md, tags v2.0.0 and builds the four platform bundles.

What's in it

Tables with very large values. A table whose jsonb column averages 601KB a row took eleven seconds to open, because SELECT * moved roughly 100MB out of TOAST for a grid that draws forty characters of it. Wide columns are fetched as their size now, measured per table rather than assumed, and the value itself loads per cell on demand. Settings → Load large values on demand turns it off.

Staged rows. Add and Duplicate append, so pressing either three times gives three rows, each editable, each with its own tick and its own discard, inserted as one batch under one confirm. Duplicate fills the draft instead of writing on the click.

Keyboard. The focus ring was invisible app-wide: Tailwind v4 compiles every outline-* utility to outline-style: var(--tw-outline-style), and outline-none sets that variable to none, so the outline-none + focus-visible:outline-2 pattern used in about forty places drew a 2px outline whose style was still none. The connection rail is one tab stop with arrows walking it, relationship columns join the cell cursor, and the hand-rolled dialogs trap focus, restore it and inert the page behind them.

Fixes worth naming. A dropped pool reads as a dropped pool rather than a SQL error. Disconnect survives a restart. F5 refreshes the table, not the document. The search highlight follows match case, regex and whole word instead of contradicting them. Staged rows sit on their columns at any scroll offset. A field inside the grid keeps its own undo and word deletion.

Verified

  • npm run build clean, 661 JS tests across 43 files, 128 Rust tests, svelte-check 0 errors
  • The fold script from auto-release.yml, run against a copy of the tree: v2.0.0, dated correctly, three sections, fifteen bullets, nothing dropped

Before merging

Actions has to be able to run. Merging while it cannot spends the only trigger auto-release.yml has (pull_request: closed) and produces nothing.

broisnischal and others added 30 commits September 15, 2026 17:35
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.
Four places in the SSE path could end a turn with nothing on screen and no
error, which is what "it loads, then nothing happens" looks like.

- A gateway can report a failure inside a 200 stream as `data: {"error": …}`.
  That frame has no `choices`, so it was skipped and the turn ended silently.
  It now throws, and the error bar shows the provider's own sentence.
- The space after `data:` is optional in the SSE spec. Providers that omit it
  had every frame dropped as unparseable.
- An endpoint that accepts `stream: true` and answers with one non-streamed
  frame puts the text in `choices[0].message`, not `choices[0].delta`. Both are
  read now.
- Reasoning models stream the chain of thought in `reasoning_content` and the
  answer in `content`. When a router sends only the former, which is what the
  `auto/best-coding` aliases do, the reasoning is kept as the fallback instead
  of being discarded.

And `runAiTurn` no longer falls off the end when a turn produces no text and no
tool call. It throws with the model's name, because switching model is nearly
always the fix.
Scrolling: the app eased scrolling itself on every platform, animating
scrollTop from a non-passive wheel listener. On macOS the OS has already
applied momentum by the time deltas arrive, so easing them again is a
second filter on a filter and every gesture trails the pointer by the
length of the ease. nativeScroll now defaults per platform - OS-owned on
macOS, eased elsewhere, where a discrete wheel genuinely benefits. A
one-shot marker applies it to existing installs, whose saved settings
already carried the old default as if it were a choice.

The ease was also frame-rate dependent: a fixed fraction of the remaining
distance PER FRAME converges twice as fast at 120Hz as at 60Hz. It is now
normalised against a 60Hz frame, with a floor and ceiling on dt.

Resizing: ResizeHandle called onresize straight from pointermove, which
runs at the pointer's rate rather than the display's. Every call writes a
panel width, reflows the shell, fires the grid's ResizeObserver and
reallocates the canvas backing store (which clears it) - several times
per painted frame. Coalesced to one call per frame, with a final flush on
pointerup so the drag lands where the pointer stopped. That covers the
related-rows dock, the inspector, the linked-table preview, the log
panel, the AI sidebar and the editor heights. The FK dock has its own
drag handler and gets the same treatment.

Grid fonts: the canvas read --fs-xs off a DOM probe while its geometry
used round(N * canvasZoom). Two formulas, so text drifted against its own
row as you zoomed - 12px in a 28px row at 100%, but proportionally 6%
smaller at 110%. Canvas text now scales from canvasZoom off the nominal
sizes ROW_HEIGHT, CELL_PAD_X and HEADER_H were tuned against; only the
family still comes from the probe. Ratio spread drops from +-3.2% to
+-1.3%.

Sidebar memory: dropping windowing for content-visibility skips a row's
layout, not its construction, and every row carried its own
ContextMenu.Root + Trigger - 10,000 menu components for a 5,000-table
schema, rebuilt on every schema switch and filter keystroke. Tables,
views and materialized views now share one menu per list, with the
clicked row read off the event, as the grid already does.

Also scales VirtualColumnsPanel and the Redis key list by --app-scale,
which the panel pass missed, and declares --app-scale in :root so the
first paint before applySettings resolves it rather than the fallback.
The count sat beside the filter input, gated by `@max-[13rem]/sb:hidden`,
and that one query was the only reason the sidebar `<aside>` carried
`@container/sb`. Declaring a size container there made the whole subtree
re-resolve its container queries on every pixel of a resize drag: 31ms of
forced layout per frame against 5.8ms without it in the same A/B, so the
drag ran at roughly 20fps. Measured on the WebKitGTK webview against a
101-table schema. `content-visibility` on the rows was not the cause -
removing it measured slightly worse, so it stays.

Each list now names itself and carries its own count in the header row
directly above it, which is what Views and Materialized Views already did.
Tables had no header at all, and Databases and Recent had a header holding
nothing but their actions. All six use the shared `countBadge` now, so they
read the same and show `12/29` with a hover title when a filter hides rows.

`countBadge` owns the `ml-auto` that pushes a row's right-hand group over,
so the Databases buttons, `Clear` and `Clear all` gave theirs up: two auto
margins in one flex row split the free space between them and left the
count floating in the middle.
Reads the rAF cadence, which is the rate the compositor is actually painting
this webview at - the number that drops during a resize drag or a grid
scroll. Shows fps, the mean frame interval and the worst single frame in the
window, because an average hides exactly the stall worth finding. The GPU
renderer sits underneath it: `llvmpipe` there means the webview fell back to
software rasterisation, which is worth knowing before blaming any of my own
code for a slow frame.

Mounted behind `import.meta.env.DEV`, so it never reaches a release build.
F8 toggles it and the choice persists. The rAF loop only runs while the HUD
is visible - an always-scheduled frame callback keeps the compositor awake
for a number nobody is reading.

Idle frames still tick at vsync, so a quiet app reads ~60. The number only
means something while something on screen is moving.
The Databases tab is a switcher, not a destination. Picking a database left
the sidebar sitting on a list of databases while the tables underneath it had
all been replaced, so the one thing I wanted to see next was always one click
away.

Driven off `activeDbKey` rather than off the click, so it fires when the
switch actually lands: a single click that opens the confirm dialog and then
gets cancelled must not move the tab, and a switch made from the context menu
or the command palette must. An empty key is a disconnect and keeps the
bookmark, since a reconnect puts the same database back and that is not a
switch; the first connection of a session keeps whichever tab localStorage
restored.

Switching connection goes through the same key, so it lands on Tables too.
Filtering down to a single row and then having to reach for the mouse, or
Tab into the list to press Enter on the one thing there, is a step that
carries no decision. Enter in the filter box now opens that row directly.

It works off whichever list the open tab is showing - a table, a view, a
materialized view, a recent table, a pin, or a database - so the key means
the same thing everywhere. Inert when nothing or several rows match: a key
that guesses which of six rows was meant is worse than a key that does
nothing. The database already open is excluded, since that row does nothing
when clicked either.

The pending 200ms filter debounce is committed before the check, or a fast
typist who narrows to one row and hits Enter inside that window is judged
against the previous search term.

Accessibility:

- The field's accessible name now says which list it filters ("Filter
  tables", "Filter databases") instead of a flat "Filter sidebar", and an
  `aria-describedby` hint offers the Enter shortcut on focus.
- A polite `role="status"` region reports the result count as the list
  narrows, and names the row Enter would open when exactly one is left.
  Nothing announced the count before - it existed only as digits in the
  header. It reads off the debounced term, so it speaks once the typing
  settles rather than per keystroke.
- Description and status are separate regions, both always rendered. Merged,
  the instruction would re-announce on every keystroke; inserted only when
  they have something to say, a polite region announces unreliably.
- The visible `↵` chip is `aria-hidden` and `pointer-events-none`. It is a
  second copy of what the description and the status already carry.
Sixteen rows of type, nullable, default and comment, each control painting
its own 2px border at the 12px field radius inside a 28px row: sixty-odd
pills stacked in a grid, every one announcing itself as a separate control
when the grid's own rules already say where one value stops and the next
starts. The doubled inset came from the same place - the pill's padding sat
on top of the cell's.

The call sites had already tried to opt out. `border-0 bg-transparent` on
the nullable select did nothing, because the frame comes from `.field-surface`
and from an unlayered bare-input rule in app.css, and unlayered author CSS
beats every Tailwind layer. So the opt-out has to live at the same level:
`.cell-fields` is unlayered, next to the `.studio-data-table tbody td` rule it
mirrors, and it names `[data-slot="select-trigger"]` explicitly - that is a
button, so the bare-input rule never reached it, which is why the nullable
column was the last one still wearing a full pill.

The edit highlight is now one thing. It was `ring-2 ring-inset ring-primary`
on type and default only, because those two were the only columns with an
open/closed state to hang it on; nullable and comment lit up in no way at
all. Driving it off `:focus-within` on the cell gives a text field, a
combobox and a select the same mark, and it is the same ring the data grid
already draws around the cell being edited. Inset, so no row shifts, and on
the cell so it traces the cell edge rather than a rounded box floating
inside it. The controls inside drop their own outline: two indicators for one
focus is what the field frame was doing.

Also, in the same view:

- The type and default pickers ran their own menu metrics (`py-1.5`,
  `text-ui-sm`) and the algorithm and NULLS pickers re-densified theirs with
  `[&_[data-slot=dropdown-menu-item]]` overrides, which DESIGN_SYSTEM §7
  forbids outright. All four use the canonical item now: `gap-1.5 px-2 py-1
  text-ui-xs`. Separators follow the canonical recipe too.
- The type panel carried a fixed `w-52`. Panels shrink-to-fit, so a fixed
  width cannot grow for a type name that does not fit and the text paints
  outside the rounded border. `min-w-52`.
- Indexes, relations and triggers were written with their own
  `border-border/40` and `text-ui-sm` while columns used `/25` and
  `text-ui-xs`, so switching tab changed the weight of the grid under an
  unchanged header. One rule for all four.

`px-3` stays the cell inset everywhere. I moved it to `px-2.5` first and that
was wrong - it is what `TH` uses, so every header came unstuck from its own
column.
I could not reproduce "it does not work", so I stopped reading the component
and gave the rule a test instead. The picking logic moves to
`sidebar-filter.js` with 13 cases covering every tab, the materialized-view
branch, the five-row recents cap and the database already open. All pass, so
whichever half is wrong, it is not this half.

Two real defects found on the way through:

- `sidebarFieldClass` carried a stray empty `hover:` variant mid-string. There
  is no such utility, and an unparseable class sitting in a `cn()` string is a
  live risk to every class beside it - this is the filter input's own class.
- The live region had no `aria-atomic`. `role="status"` implies it, but not
  every assistive technology honours the implicit value, and without it
  "1 of 14 tables. Press Enter to open ingest_jobs." is announced as whichever
  fragment changed rather than as the sentence. That is the likeliest reason a
  screen reader read nothing useful.
…does

Enter still did nothing, and the reason is that I counted a model instead of
the rows on screen. Those can disagree, and only one of them is what you are
looking at: a tab drawing two sections, an empty-state row, a list still
holding the previous tab's rows. Worse, the activation path was a second
implementation of what a row click does, so it could be wrong in a way the
row itself was not.

Both go away by asking the list. If exactly one row is drawn, Enter clicks
that row's own button - so Enter and a click cannot drift apart no matter
what a row grows into later, and every tab is covered without a per-tab
mapping. `flushSync()` runs first, because committing the pending filter only
sets state and the DOM is a frame behind it until the render lands; counting
rows before that reads the previous search.

The model stays for the announcement and the hint, and as a fallback for the
one case the DOM cannot answer: a list that is not rendered at all.

The hint is now a real target rather than a legend. With one row left, the
quickest thing to do is open it, and a chip that only names a key sends the
pointer the long way round to a row already on screen. It is `tabindex="-1"`
on purpose - Tab from the filter goes to the list, which with one match is
this same row, so a stop here would be a second stop on one thing. The
keyboard path is Enter, which the field's description already offers.
The pills were still there, and only half of them: the nullable column went
flush while type, default and comment kept theirs. That split is the whole
diagnosis. Select triggers get their frame from `.field-surface`, which sits
in `@layer base`, so any unlayered rule beats it. Inputs get theirs from the
bare-input rule, whose seven `:not([type=...])` guards each count as an
attribute selector - it lands at (0,9,1) and `.cell-fields input` is (0,1,1).

Both rules are unlayered, so specificity decides and mine lost, silently, on
exactly the half that is an `<input>`.

`!important` on the three properties. The only way to out-specify that chain
with a container class is to repeat the class nine times; the only other route
is threading `.no-field-frame` through every field in the grid, which is the
per-call-site opt-out this container class exists to remove.
The chip never appeared even with one row left, while the section header -
reading the very same `filteredRegularTables.length` - said 1/14 right beside
it. I could not make those two facts agree by reading the component, and a
model that disagrees with the rendering is not worth debugging twice.

So both the chip and Enter now read the rows themselves. `renderedRowCount` is
an effect over the rendered list, and `$effect` runs after the DOM updates, so
what it counts is what is on screen. One source of truth for "is there exactly
one thing here", and it is the thing being looked at.

The model survives only as wording: it supplies the row's name for the chip's
label and the announcement. Where it disagrees, the feature still works and
only the phrasing degrades to "the only match" - the wrong way round from
before, where the phrasing was fine and the feature was missing.
WebKit ships PreferPageRenderingUpdatesNear60FPSEnabled on by default, which
clamps the whole rendering update - rAF, CSS animations, the compositor commit -
to ~60Hz whatever the panel can do. On a ProMotion display that is half the
frames the hardware is already refreshing at, and it shows as judder wherever the
main thread draws while the scrolling thread runs at 120.

No public API for it, so it goes through WebKit's own feature registry. Every
underscore selector is checked before it is sent, so an older or newer WebKit
stays at 60 rather than trapping.
Pinned was a fifth icon in the strip holding, for most connections, nothing -
and it split 'the tables in this schema' across two places. It renders above the
Tables header now, which is where pins already sorted in selectableOrder.

Pins were never filtered, which only shows once they share a box with the table
list, so they take the same predicate. The tab's count had to absorb them too:
it read 0 on a schema where every table was pinned.

Pinning moves a row UP and out of the list, so the offset follows it to the top;
unpinning moves it back down to a sorted position nobody asked to scroll to, so
that one holds the list still instead. WebKit has no overflow-anchor, so the
anchoring is by hand.

Row hover swapped display on two icons per row, which is a layout invalidation
for every row crossing the cursor mid-scroll. Opacity is paint-only.
tableViewMode was one module-level $state shared by every open table, so it did
not just fail to persist - it leaked, which is why openTableTab carried two lines
scrubbing it on the re-activate path. It travels in the tab snapshot now, with
structureColumns beside it so a tab restored into the structure editor paints its
own columns instead of flashing the previous tab's.
The app-wide field rule is unlayered on purpose so a local class cannot pull a
control out of the system, which means a frameless input nested in a bordered
container has to opt out with .no-focus-ring. Eight never did, so each drew a
pill inside its wrapper's pill and left the leading icon stranded outside it.

The JSONPath bars hid from an earlier sweep because their placeholder contains
'>' (from '[?(@.x > 0)]'), which terminates any <input[^>]*> match before the
class attribute.

Also in the diff toolbar: the tabs share one baseline instead of drawing six
disconnected border-b stubs, the active marker is neutral rather than restating
the label's status colour, and the row count stays visible while searching -
the count IS the result of the search.
bind:value on <input type="number"> yields a JS number and instance_set_config
takes an Option<String>, so Tauri rejected the call outright: 'invalid type:
floating point 0.2, expected a string'. That is most of the 364 settings. The
field is text with inputmode now - the spinner was wrong anyway, since half of
pg's 'integer' settings are written with a unit (8MB, 1min) that a number input
will not accept - and api.js enforces the contract so no caller can resend it.

Accessibility pass over the same rows: the focus style was the hover style with
the outline removed, so a keyboard user could not tell which of 364 rows they
were on. 'New value' is a real <label>, the on/off pair is a group that states
its own state, and aria-expanded now points at the panel it controls.
$[*]["day","requests"] already matched the quoted-property branch: that regex
is greedy, so it captured the single key 'day","requests' and returned
[null, null] with ok:true. A silent wrong answer, not a missing feature. The
union branch sits ahead of it now.

Output is an object per element rather than RFC 9535's flat list of values -
two keys over 60 rows is 120 loose values with nothing saying which is which,
and this view renders rows. Index unions keep the order listed, not the array
order. A comma inside ?(...) is not a separator, and one inside a quoted key
survives; both are tested.

The suggestion footer rendered '↵' and 'tab' as adjacent caps sharing one label,
which reads as Enter doing nothing. It also gave no hint the list scrolled, so a
12-item list looked like an 8-item one: the count is position-over-total now,
with a fade while more remains.
New in Appearance: grid text size (independent of app zoom, clicking the reading
resets it), group large numbers, highlight the active row, image previews, open
links on click. Rows-per-page is surfaced rather than duplicated - table-query.js
already owns it, and a second copy would be one the grid's own dropdown never
writes to.

Row spacing had no redraw of its own. contentHeight and spacerHeight are
$derived, so the scrollbar resized instantly while the canvas kept the pixels it
drew at the old row height - rows only resized once something else triggered a
paint. That predates the new rungs; all five were affected.

Row spacing gains Dense and Spacious; table styles gain Double, Hairline, None,
Ledger, Graph, Bands and Ticks, off two new renderer flags. The group rule
batches into the same path, so twin lines cost one stroke per row.

NULL and boolean display stay with their extensions rather than getting settings
here: formatters run after formatCell and replace it, so a setting would be dead
whenever the extension was on. Boolean Glyphs gains 1/0, on/off, yes/no and a
dot-only style instead, and every style carries a word or shape so the split
survives a monochrome screen.
isTauri() is true for a 'npm run tauri' dev run as well as a real build, and a
dev run serves from localhost:1420 - an origin the endpoint rejects. Every flush
failed CORS and logged three errors into the one console a developer is reading.
Counting a developer's own clicks as product usage was wrong data anyway.

The module also owned a setInterval and a window listener in module scope with
no dispose, so a hot update left the OLD timer firing the OLD flush - a fix to
flush appeared not to work until a full reload - and stacked one more interval
per update, all of them posting.
dompurify was overridden only for mermaid, so monaco-editor stayed on 3.2.7 -
14 of the 18 moderate alerts. One global override takes both to 3.4.15.
vitest 4.1.9 -> 4.1.11 for the mocker path-traversal advisory; that one was
already inside the declared ^4.1.9 range.

Two could not be patched and are dismissed with the reasoning on the alert.
echarts needs a 5-to-6 major bump for an XSS in the Lines series tooltip, and
buildOption() - the only place an option is constructed, including for AI specs -
never emits type:'lines'. glib's unsoundness is Linux-only and pinned by Tauri's
gtk 0.18.

Also: TableStyleId had not been told about the seven styles added with the
group-rule and tick flags, so every one of them type-checked as invalid.
The panel was positioned in content space, so native scroll moved it - on the
compositor, for free. The rows above and below it are canvas pixels, repainted on
the main thread from _scrollTop, which the rAF loop reads once a frame. Two
clocks driving two things that have to stay glued together: on any frame where
the compositor has scrolled and the repaint has not landed, the panel and the
rows are offset and the gap the canvas reserved shows at one edge.

It is positioned off the same _scrollTop the canvas draws from now, so both move
on one clock. transform rather than top, because the value changes every scrolled
frame and transform is the one property that moves a box without laying it out or
re-rastering its contents - and rounded, since a fractional offset makes WebKit
re-antialias the panel's text every frame, which shimmers on its own.

This also collapses the two branches into one. The near-viewport render guard
still only applies to scaled/huge tables, where content y would be past WebKit's
layout range; a normal table keeps its panel mounted, so scrolling past an
expanded row no longer unmounts and rebuilds the whole JSON tree.
The ease wrote a fractional el.scrollTop for the whole animation and rounded only
when it landed. The reason given at the landing - the grid draws text at integer
offsets, and a fractional scroll makes WebKit re-antialias every glyph - applies
to the forty frames before it, which is where the cost was actually paid.

It matters more for what the grid does NOT draw. The expanded-JSON panel is a DOM
element in content space, so the browser positions it from this exact value while
the canvas rows around it are drawn at Math.round() of it. That left the panel and
its rows up to a pixel apart, by a different amount each frame: the flicker seen
when scrolling with a row expanded.

curTop stays fractional - rounding the accumulator would let a sub-pixel step
round away to nothing and stall the animation short of its target.

Also reverts cb8b1d8, which moved the panel to a per-frame transform. That
targeted a real desync but made it worse: it re-rastered the panel every frame
and promoted it to its own layer, on top of the sub-pixel mismatch above.
… reads

Rounding el.scrollTop on every frame was wrong. The ease moves curTop by a
shrinking fraction, so the tail of a scroll advances less than a pixel per frame -
at 120Hz, where each frame carries about half the delta it did at 60. Rounding
the write turned that tail into 0,0,1,0,1 and every gesture ended in steps. The
sub-pixel offset it leaves against the canvas is under half a pixel; the stepping
it cost was not worth removing that.

formatCell and drawCell each read a store directly, and each runs once per
visible CELL per frame, so that was thousands of store_get calls a frame at
120Hz. Mirrored into plain locals, which is what the frame context already does
for $appTableAlign and the table style. Both reads were mine, added with the
image-preview and number-grouping settings.

Also lets a blit run while a row is expanded. A vertical scroll is still a pure
translation there: the panel's gap is bare background and translates with
everything else, the strip loop places rows by rowViewportY() which accounts for
the expansion, and the exposed strip is cleared before anything is drawn into it.
The one event that is not a translation - the panel's measured height changing -
already forces a full frame through the geometry effect. Holding the guard cost a
full repaint of every visible row on every frame for as long as any row was
expanded.
An empty option list renders the chip with pointer-events-none, so a failed fetch
and a genuinely empty schema looked identical - an unclickable grey chip, no
message, nothing to retry. Both loaders caught and discarded the error, and a
dropped connection is the common way to get there.

Each side now carries its own error and prints it under the row.

loadTables also had a fast path that reused the open connection's table list
whenever the connection and database matched, without checking the SCHEMA
matched too. The `tables` prop is the active schema's list, so picking any other
schema on the current connection listed the wrong tables under it. fetchDatabases
was the one unguarded await left in loadDatabases; it now falls back to an empty
list rather than rejecting out of the chain.
The pickers now say when a list failed to load, which leaves the obvious next
question - how to try again - with no answer. Mod+R already means "reload what
this page is showing" on every other tab, and on this page what it shows IS the
source and target lists, so it dispatches here now like security and dashboard
do. A Refresh button sits beside Compare rather than in the header: that is the
row you are on when a picker turns out to be empty.

Both sides rebuild from the connection down rather than retrying the single call
that failed, because a dropped connection usually takes the whole chain with it
and the chips below the failure are stale regardless. Selections survive where
the new lists still contain them.
The active tab is carried by weight and colour instead: the label goes medium and
takes its status colour while the rest stay regular-weight muted, and the count
badge picks up the same tint. Still two cues, not colour alone, so the selection
reads without a rule under it.

The strip keeps its own baseline - that one separates the tabs from the header
band below and is not the selection marker.
The first attempt used SelectMenu with contentClass anchored to the cell width
and font-mono. On a narrow column the popup inherited that width and squashed the
search field down to its bare icon, which is why it looked like a stray caret
with no way to type.

It now uses SearchableMenu directly, configured the same way the filter-condition
menu in TableToolbar is - same primitive, fixed w-56 content, same trigger and
item snippets - so the two read as one control rather than two takes on it. Sans
rather than mono in the list: every other menu is sans, and these are short
labels, not data being compared character by character. The trigger stays mono,
because that one sits in the grid among values.

Picking commits and closing without a pick cancels, as before. commitEdit is
async, so the close that follows a pick still has editingCell set and needs the
flag to tell it apart from a dismissal.
The unclamp had six silent exit paths. If WebKit renames the feature key
or drops one of the SPI selectors it leans on, the function returns
quietly and the app sits at 60fps - which on a Mac looks exactly like a
60Hz display. Nothing in the app could tell the two apart.

Every exit now says what it did. A match logs the flag going off; falling
off the end of the scan logs how many flags it walked, which is the
healthy path on macOS 26 where Apple dropped the clamp and the flag with
it, and the renamed-key case on 13-15 where the clamp is still on.
Duplicate row wrote to the database on the click. One keystroke, one row,
no chance to change the field that made you want a copy in the first place.

It fills the insert draft now - the same band the Add button opens, pinned
under the header with every field editable. The key and any generated column
take their usual defaults rather than the source row's values, which is what
would have made the insert collide with the row it came from. Nothing is
written until the draft is submitted, and that path already runs through the
DML confirm.

The chord moves to Alt+D, beside Alt+F and Alt+E: all three act on what the
cell cursor is standing on.
The fetch ceiling was cutting values in half and the pane was reporting the
result as invalid JSON, which was true and useless. The row this was found
on is 8.4MB on disk and 18.1MB as text - a jsonb holding a file as an array
of byte integers runs about 2.2x larger as text than stored - so a 4MB
default stopped it at exactly 4,194,304 characters.

The default is 32MB now, the hard ceiling 64MB, and the size reported is
`octet_length(col::text)` rather than `pg_column_size`: the caller is about
to render text, and the compressed on-disk size says little about how long
that text is. Truncation is decided in SQL, in the units `left` cuts in,
instead of inferred from the string that came back - and when it does
happen, the pane says the value was cut rather than blaming the JSON.

The panel also stops doing whole-value work on a value that size. Above 2MB
the tree waits until asked for (a parse of 18MB is felt, and a derived reran
it on every keystroke), the find box stops walking the string twice per
keystroke, the highlight layer stands down, the line count is skipped, and
wrapping is off - one 18MB line laid out across the pane is the most
expensive thing this panel can be asked to do.
Loading one froze the window, and the raw pane showed why: one array element
per line. `toText` pretty-printed everything, and a jsonb holding a file as
an array of byte integers indents to one number per line - 16MB of compact
JSON became 41.8MB across three million lines, which is a textarea no
webview can lay out. Past 512KB the text now stays exactly as it arrived.

Two ceilings, because "how much can be fetched" and "how much can be shown"
are different questions. The dock asks for 8MB, which is well past anything
anyone reads and is where the panel stays usable. A grid cell asks for 1MB
and refuses a truncated answer outright: that value goes into the row the
canvas formats every frame and the search walks every keystroke, and a cut
value in a cell is worse than the size it replaces, because it reads as the
value and is not one. The dock holds more, and says what it holds.

Shift+click previews a cell in the dock too - the pointer half of
Shift+Space, which is a chord to reach for when the cell is right there.
It was bound to nothing, so the webview took it and reloaded the document -
which tears down the session, redials the connection, re-reads the catalog
and refetches every list, for what anyone pressing F5 over a table means:
fetch these rows again.

It runs the same contextual refresh ⌘R does, so over a table it is
`loadRows()` and nothing else. The default is always prevented, including
while disconnected or with a dialog open, so a full reload cannot happen by
accident with edits staged.
Alt+⌫ discards staged changes, but the grid's own Backspace case only stood
down for ⌘/Ctrl - so Alt+⌫ started a cell edit, focus moved into an input,
and the app-level handler then bowed out of it the way it bows out of any
text field. Both Delete and Backspace now only clear a cell when nothing is
held down; every modified version belongs to someone else.

The raw pane came up empty on an 8MB value. A `<textarea>` handed that much
renders nothing at all here, which is worse than slow, so past the heavy
line the raw side is a read-only window onto the text - 128KB at a time,
with Back and More and a position readout. It paints instantly at any size
and the whole value is still reachable.

Two smaller ones: the dock re-reads the cell after the in-cell Load writes
it, so the panel stops saying "not loaded" over a row that already holds the
value; and the Load button no longer appears (as "Load 0 B") once the value
is loaded and the only thing left is that the server stopped at its ceiling.

Clearing the search runs immediately instead of waiting out a second
debounce. The toolbar already coalesces typing for 250ms and this debounced
it again, so emptying the box sat for another 150ms while the search that
was already in flight painted its rows - which reads as the search carrying
on after you cancelled it.
The paged raw view is editable now, on one condition: the whole value has to
be present. Typing into a window onto a value that was cut at the fetch
ceiling would stage what was loaded over what was not, which is data loss
rather than an edit - so it stays read-only there and the badge says which
of the two it is.

The window is its own state rather than a slice of the draft, because
re-slicing on every keystroke cuts the character just typed off the far
edge. An edit splices back into the full value, so Stage change still sends
the whole thing.

Two costs removed. The loading-spinner key was a string formatted per capped
cell per frame to ask a question whose answer is almost always "none of
them"; it is only built while something is actually loading. And closing the
dock releases what it held - a loaded value can be 8MB of string, and
keeping it referenced after the panel is gone is 8MB retained for a panel
nobody is looking at.
`pg_column_size` is the compressed size, and these payloads compress
extraordinarily well: the row measured for this ran 8.4MB stored against
18.1MB of text, and rows comfortably under the cap on disk were still
shipping megabytes each. The projection now checks the text length too, for
anything over 4KB stored - below that no amount of compression can reach the
cap. Measured on a 200-row page of the table this was written for: 426ms
without the check, 683ms with it. That is what makes the page bounded rather
than hopeful, and it is the difference between a page that arrives and one
that wedges the app.

⌘M was dealing out the hidden theme like any other. An easter egg is
something you go and find; `hotdog` in particular is not a theme anyone wants
to land on mid-session. It still selects from the picker once found, and
standing on it still cycles out.

The SQL tile on the welcome screen printed ⌘T, which opens the command
palette on its tables page. The SQL view is ⌘⇧S.
…jects

The draft was one component's state serving every tab, so the Add row you
started in one table was sitting in the next one you switched to, over a
different set of columns. It belongs to its table now - except when nothing
has been typed into it, because an untouched draft still costs a row of
height and made every tab switch shift the rows underneath.

Duplicating a row into that draft produced "answerField: Invalid JSON". A
json column takes JSON text and `valueToEditString` hands back the bare
value for anything that is not an object, so a json column holding the
string "sdf asdf" was copied in as sdf asdf. It is re-encoded now.

An open draft counts as a pending change, so the status bar's Apply and
Reset appear for it. Apply commits one thing per press - the insert goes
through the DML confirm, which is a dialog the user can cancel, so awaiting
it would leave Apply hanging on a promise that never settles.

⌘F on the database objects page focuses its search box instead of doing
nothing. That input also picked up an aria-label, Escape-to-clear, and lost
a stray `focus:` that was not a class.

The search results page reads as one column now. A result is a table name, a
count and a sample of the row it matched, and flinging the count to the far
edge of a 1400px pane put 1300px between a number and the thing it counts.
Add and Duplicate append now. Pressing either three times gives three rows
stacked under the header, each editable, each with its own tick and its own
discard - and the first row's tick inserts the whole batch, as does Apply and
⌘↵. One confirm covers the batch: reviewing three inserts is one list of
three statements, not three dialogs. A row that fails stays staged with
everything still typed into it, because losing four filled-in rows to the
third one's constraint is not a trade worth making.

Alt+↵ adds another row below without reaching for the button, Escape drops
the row you are in rather than the whole band, and focus is scoped to the
staged row that asked for it - a global lookup by column name landed in the
first band every time, so the caret jumped to the top row on every move.

The band reads as part of the table again: one ring per row drew a line
between every pair on top of the border already there, the column rules were
too faint to make cells, and the fields carried a UI-scale font while the
rows underneath are drawn at the grid's own size.

Relationship columns: ⌘/Ctrl+click opens the related rows as a view and
⇧+click puts them in a new tab, matching what those modifiers already do on
a foreign key. Their headers start at `CELL_PAD_X` like every other header -
they were indented to the cell text below, which lined them up with their own
values and out of line with every header beside them.
The focus effect read the drafts, so every keystroke re-ran it and a re-run
moves the caret - typing in the second staged row put the next character in
the first one. It depends only on what asks for focus now, and it leaves the
element alone when it is already focused, so a value update cannot drag the
caret to the end of the field.
Duplicating twice did nothing the second time. `handleTableKeydown` bailed on
any open draft, so every grid chord went dead the moment one row was staged -
Alt+D among them. Keystrokes that originate inside the band now return to the
band, which has its own handler, and everything else stays the grid's. That
same guard had to move to the top of the function: ⌘A sits above it and was
selecting every row in the table while the caret was in a draft field, where
it means "select this value".

Right-click a staged cell for what you would otherwise type by hand: generate
a UUID or a CUID, drop in now/today, zero a number, copy, paste, clear back
to the column's default - and, with more than one row staged, fill this
column in all of them, which is the difference between typing a tenant id
five times and typing it once. It is one positioned box rather than a
ContextMenu per cell, because that would be a menu instance per column per
staged row, mounted and torn down on every keystroke.

Alt+N stages a row, joining the Alt family the grid uses for what is in front
of you (Alt+F filter by this value, Alt+E exclude it, Alt+D duplicate this
row). It appends, so three presses is three rows. Mod+Escape discards the
whole band; plain Escape drops the row you are in.

The draft's date fields wear the same shape as the inline cell editor -
calendar on the trailing edge, value at the grid's type size - instead of the
picker's own layout, which made a staged row look like a form dropped on the
table. And `isDateTimeType` was used in the new menu without being imported,
which would have thrown on the first right-click over a timestamp.
Three ways it did not.

Horizontally it moved with native scroll while the columns behind it repaint
on the next frame, so dragging a wide table sideways slid the two past each
other. It is pinned now and translated by the same `_scrollLeft` the canvas
draws with, exactly like the canvas's own sticky wrapper.

It laid out every column, hidden ones included - a different width and a
different set of x positions than the grid underneath, so one hidden column
slid every staged cell after it out of line with its header, and the drift
was widest at the far end where the relationship columns sit. Hidden columns
take the database's default; unhide one to type into it.

And ⌘A still selected every row while the caret sat in a draft field. The
guard was checking for the band specifically; it checks the element instead
now, so any field inside the grid keeps its own keys - the inline editor and
a picker's search box included, without anyone remembering to come back here.

Enum and boolean fields in the band use the same searchable menu the inline
cell editor uses, with the blank row still first saying what leaving the
field alone does.
Wide columns, staged rows, the focus ring, and the rest of what this branch
changed - written for the release notes rather than for the diff.
`insertMissing` was computed and never read - the draft still let you press
Insert on a row with a required column blank and learn about it from a failed
round trip, which names the column in a toast while the field it is about is
on screen with nothing pointing at it.

Submit now refuses that row, puts the caret in the first column it needs, and
names them. The row's tick turns amber and says which ones while there is
still something missing, so the state is readable before anyone presses
anything.
Dragging a wide table sideways slid the insert band against the grid it
sits on. Two things were wrong, and both came from the band working out
its own geometry instead of reading the canvas's.

It summed the gutters by hand and left the row-number one out, so every
staged cell sat that gutter's width left of its column - invisible with
row numbers off, a whole column's worth of drift with them on. And its
horizontal pin was a sticky box in the sizer, which has to satisfy its
own `left` inset and its containing block's right edge at once; which of
those wins depends on how wide the box is, so the pin held or didn't
according to how many columns the table had.

Now every x and every width in the band comes from `geom` - the object
the canvas draws from - and the band lives inside the canvas's own sticky
anchor, so the two measure the viewport's left edge from the same box.
Pinned columns freeze through `colDrawnX`, the same call the canvas
makes. One transform per staged row moves the whole thing; the cells
inside never move relative to each other.

`geom` also resolves widths with the column's real type now. It passed
'' and the band passed the type, which is the same answer for a column
with a stored width and a different one for the frame between a table
switch and the effect that seeds them.

Tab inside the band scrolls the column into view. The band is pinned, so
the browser has nothing to scroll when focus lands on a field off to the
right - it left the caret on a cell nobody could see.
Ctrl/Cmd+Z with the caret in a staged row undid a cell edit somewhere
else in the table, and the field's own undo never ran. The grid's
undo/redo/copy listener is bound on `window`, which puts it ahead of
every other capture handler in the app - including the one that gives
inputs their undo stack and their word deletion. It stands down now for
anything you type into, the same test the container's keydown already
applies, and that test is one function instead of two copies.

Alt+Backspace deletes a word on Linux and Windows too, not only on macOS.
Muscle memory is the smaller reason; the real one is that an unclaimed
chord reaches the hotkey layer, and Alt+Backspace is bound there to
discard staged changes - so pressing it mid-sentence threw the row away.
Claiming it in the field is what stops the field being overruled.
Its tooltip read "Insert row (Add)". "Add" is the button's label, not a
chord, so the one place anyone looks for the shortcut told them nothing.
It says Alt+N now, in the platform's own notation like every other
button in the toolbar.

Alt+N also works from inside the staged band. It was guarded against
firing while you type, which is right for the search box and wrong for
the one field where "again for another" is the whole point - and Alt+N
is not a chord any text field claims.
Clicking the load button on a 1.2MB resume said it was too large for a
cell and sent you to the dock. The cell's ceiling was 1MB on the grounds
that its value is what the canvas formats and the search walks - true,
but neither reads more than the forty characters a cell is wide: the
drawn text is cut to 400 characters and cached per row, the highlighter
matches that same cut string, and the row search runs in SQL. It was
charging for a cost nobody was paying.

The ceiling is the dock's 8MB now, one number for both, and the refusal
past it says what it means: nothing loads a value that size in one
piece, so read it in pages.

What does cost something is the parse. Drawing an object means
stringifying it, so a parsed 6MB payload is a 6MB parse on the way in
and a 6MB serialize on the way out, both blocking, to show forty
characters - and it retains both copies. Past 1MB a JSON value stays the
text it arrived as. The cell draws the same thing either way and the
dock reads JSON out of text perfectly well.

The changeset covers this, the field-editing keys and the Add shortcut.
The fold step keys off the headings, so what the file calls a section is
what ships. It had `Editing` and `Fixes`, which no published entry uses -
the changelog's own vocabulary is New Features, Bug Fixes, Changes and
Performance, with `####` sub-sections under them. Every bullet is the
same text; only the headings it sits under changed.

Checked by running the workflow's fold script against a copy of the tree:
v1.25.0, dated today, three sections, fifteen bullets, nothing dropped.
@changeset-bot

changeset-bot Bot commented Sep 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a1c682a

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@broisnischal broisnischal added release:minor Bump minor version (0.x.0) release:major Bump major version (x.0.0) and removed release:minor Bump minor version (0.x.0) labels Sep 22, 2026
@broisnischal broisnischal changed the title Large values, staged rows, and a keyboard that reaches the whole app Stroke 2.0 Sep 22, 2026
The repo lives at stroke-app/stroke now. GitHub redirects the old URLs,
which is what keeps the updater in every already-installed 1.x build
working, so broisnischal/stroke must never be reused - but nothing new
should be written against a redirect. The updater endpoint, the clone and
issue links, the Homebrew and Scoop instructions, the cask dispatch and
the crate and package metadata all name the real location now.

Two things deliberately keep the old name. `com.broisnischal.stroke` is
the app identifier: it decides where settings and connections live on
disk and which installed app an upgrade replaces, so changing it would
hand every user a fresh, empty copy of Stroke. And the CLA allowlist is
a GitHub username, not a repo.
The changeset covered fifteen bullets out of a hundred and ten commits.
It was written when the branch was about wide columns and staged rows,
and the branch kept going: file import, explicit transactions, migration
SQL from a schema diff, the docked cell editor, the rebuilt connection
screen, four themes, a menu bar, per-tab structure views, and the move to
the stroke-app org, none of which a reader of the old file would have
known shipped.

Forty-one bullets now, grouped the way the changelog groups things, with
the fold script run against a copy of the tree to check it: v2.0.0, four
sections, nothing orphaned, nothing dropped.
@broisnischal
broisnischal merged commit efb02c2 into master Sep 22, 2026
1 check passed
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 22, 2026
@broisnischal broisnischal linked an issue Sep 23, 2026 that may be closed by this pull request
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

release:major Bump major version (x.0.0)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

update field refresh issue

1 participant