Repository navigation
Stroke 2.0 - #86
Merged
Merged
Stroke 2.0#86
Conversation
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.
…s between" This reverts commit cb8b1d8.
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 detectedLatest 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 |
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Merging this with the
release:majorlabel cuts v2.0.0. The workflow bumpstauri.conf.jsonandpackage.jsonfrom 1.24.0, folds.changeset/wide-columns-and-staged-rows.mdintoCHANGELOG.md, tagsv2.0.0and builds the four platform bundles.What's in it
Tables with very large values. A table whose
jsonbcolumn averages 601KB a row took eleven seconds to open, becauseSELECT *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 tooutline-style: var(--tw-outline-style), andoutline-nonesets that variable tonone, so theoutline-none+focus-visible:outline-2pattern used in about forty places drew a 2px outline whose style was stillnone. 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.
F5refreshes 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 buildclean, 661 JS tests across 43 files, 128 Rust tests,svelte-check0 errorsauto-release.yml, run against a copy of the tree:v2.0.0, dated correctly, three sections, fifteen bullets, nothing droppedBefore merging
Actions has to be able to run. Merging while it cannot spends the only trigger
auto-release.ymlhas (pull_request: closed) and produces nothing.