Status Essentially done

Sheet 01 of 08/General arrangement

Systems & graphics engineeringDrawing 25 of 25Role Solo designer and engineer

Velm

I built a native infinite-canvas app in Rust to replace Miro on my own machines. The whole thing rests on one rule: a frame should cost what is on screen, not what exists on the board.

Specification / as recorded

Status
Essentially done, 2026
Role
Solo designer and engineer
Built with
Rust, wgpu, Loro CRDT, SQLite, 18 crates
Runs on
macOS and Windows, no webview
Reference board
a real 596-widget Miro import
Velm's board library window showing a grid of board thumbnails with names and item counts, and a folder sidebar
PlatePL-01
TitleBoard library
ViewGeneral
41 boards migrated off Miro, each listed with its item count and opened from a thumbnail rather than by loading it.

Sheet 02 of 08/Why

Miro got slow, and it was the wrong kind of slow

I keep about fifty-eight Miro boards. Miro had become the slowest thing on my computer: roughly 295 MB of Electron with a gigabyte and a half of local cache, shipping an entire browser to draw rectangles.

What bothered me was not that it was slow. It was what it was slow in proportion to. A board did not get heavy because I was looking at a lot. It got heavy because a lot existed, somewhere, off screen. That is not a setting you can change or a plugin you can install. It is an architecture, so I wrote a different one.

A large imported board in Velm with a black performance overlay in the top left reading 596 of 596 visible
PlatePL-02
TitleReference board
ViewDetail
A real 596-widget Miro import, with the performance HUD on: 596 of 596 items visible, 600 draw calls, 218 images, 145 MB resident.

Sheet 03 of 08/Rule

Cost what is on screen, nothing else

Everything in the codebase is downstream of that sentence.

An R-tree spatial index answers "what is visible" before anything else is touched, so viewport queries and hit tests cost time proportional to the items in view, not the items that exist. Visible quads go to the GPU in one instanced draw rather than one draw per widget. Rounded rectangles, borders and shadows are signed distance fields evaluated in a shader, so they are exact at any zoom and never get tessellated. There is no garbage collector, which matters because JS canvas apps stutter during collection precisely while you are panning.

World coordinates are f64 and rebased to camera-relative f32 every frame. That sounds fussy until you notice the reference board is 41,282 by 17,515 pixels, and f32 alone starts losing precision around ten million.

You can watch the rule work. On a synthetic 100,000-item board at a zoom I would actually use, the overlay reads 233 of 100,000 visible, 33 draw calls, 59 MB resident. Pull back to 4 percent and it reads 87,687 of 100,000, because now everything really is on screen and there is nothing left to cull. Those two pictures are the honest version of the claim.

Colourful sticky notes filling the canvas, with a HUD reading 233 of 100000 visible at 100 percent zoom
PlatePL-03
TitleCull working zoom
ViewDetail
A synthetic 100,000-item board at a zoom you would actually work at: the HUD reads 233 of 100000 visible, 33 draw calls, 59 MB resident.
The same 100,000 item board zoomed far out into a dense confetti field, HUD reading 87687 of 100000 visible
PlatePL-04
TitleSame board pulled back to 4 percent
ViewDetail
Where the culling has nothing left to remove: 87,687 items on screen, and the frame budget goes with them.

Sheet 04 of 08/Native

No webview, decided first

An early draft put the renderer inside a Tauri webview and compiled the engine to WASM. I reversed it, and I wrote down why, because it is the kind of decision that gets re-proposed.

Tauri closed WebGPU flag support as "not planned", so there is no supported way to influence the embedded webview's GPU behaviour. WKWebView was frame-capped at 60fps until macOS 26, which put the target physically out of reach inside the host I had chosen. WebView2 can quietly fall back to software rendering. And WASM costs recur daily: no threads without cross-origin isolation, no system fonts, and every decoded image crossing native to IPC to JS to WASM to GPU with at least two copies on the way.

Native wgpu talks to Metal and DX12 directly. The same reasoning made me skip the standard egui-wgpu bridge for the interface: it pins an older wgpu than the renderer needs, and two wgpu versions cannot share a device. Adopting it would gate every future graphics upgrade on somebody else's release schedule, which is the webview problem again in miniature. So the render crate draws the menus and panels itself. A few hundred lines bought permanent version independence.

The door I did leave open: winit and wgpu also compile to wasm32 with WebGPU, and in a real browser rather than an embedded webview, WebGPU works properly. A web viewer later reuses the renderer as it stands.

A research board about mechanical keyboards, mixing product photographs, yellow sticky notes and CAD drawings
PlatePL-05
TitleOne of my own boards in daily use
ViewDetail
Holding photographs, stickies, notes and drawings that all came across from Miro in the import.

Sheet 05 of 08/Import

The boards had to survive the move

This is the part that had to work before anything else mattered, because the boards being moved cannot be re-imported freely. Miro encrypts the .rtb backups server side. The REST API returns no content for a large share of items. The clipboard route needs the original board still open in front of you. A board lost in a refactor is lost for good.

So the importer decodes Miro's clipboard format and reads the encrypted backups, resolving assets by resource id across the whole archive set rather than one board at a time. That is what makes a fifty-eight board migration one pass instead of fifty-eight. Of the reference board's 596 widgets, 532 come across intact, 89 percent, covering ink, images, link previews, text, stickies, embeds, connectors and frames. The clipboard route actually carries more than the API does: it includes pen drawings that no Miro endpoint exposes.

Correctness is checked against Miro's own SVG export as an oracle. A clipboard import of the reference board has to reproduce 44 stickies, 12 frames, 47 shapes, 18 connectors, 134 ink paths and 429 text strings. The two formats come out of different Miro code paths, which makes agreeing with both the strongest signal available.

Data safety then became the first rule in the project's documentation, above the performance goals, and it is structural rather than careful. Delete writes a path into a trash sidecar and touches no file. A single confirmed purge is the only code in the app that removes a board. A schema change has to keep reading the old spelling instead of migrating to the new one.

A grid of around forty outlined vector shapes including arrows, cylinders, callouts and flowchart symbols
PlatePL-06
TitleEvery shape the app can draw
ViewDetail
Laid out at once: rounded rectangles, polygons, callouts and the flowchart set, all signed-distance fields rather than tessellated meshes.

Sheet 06 of 08/Scope

Two things I cut on purpose

Collaboration is gone entirely. Not deferred, cut. I do not use it, and removing it took presence, cursors, comments, voting and sync out in one line. The document is a Loro CRDT anyway, chosen for undo, redo and version history rather than for sharing, so the door stays open at no ongoing cost if I ever change my mind.

The app is light mode only. The dark tokens exist in the theme crate, unreachable and tested, so turning them on later is cheap.

Beyond that it is a real tool: 41 shapes including 20 flowchart forms with rotation-correct connector anchors, tables with merges and auto-fit, mind maps with tidy-tree layout, kanban and timeline views, charts, link cards that fetch their preview once, cross-board search, and export to PNG, PDF, SVG and CSV. Storage is one SQLite database per board in WAL mode, with a shared content-addressed blob store behind it keyed by BLAKE3, so an image used on twenty boards is stored once.

Nested application menus open over a board covered in red handwritten annotations and figures
PlatePL-07
TitleMenus
ViewDetail
The menus are drawn by the renderer itself rather than by an off-the-shelf bridge, over a board carrying imported pen strokes Miro's API will not export.

Sheet 07 of 08/Limits

What it still does badly, and why I write it down

Frame cost scales with the whole board when the whole board genuinely is on screen. Text ends the quad batch once per item, and ink and connectors re-tessellate every frame, so a fully zoomed-out view still costs what it costs. The batching work that would fix it is the next real job.

Inserting into the document is O(n squared) in the CRDT. A thousand items open in 18 ms; a hundred thousand take about twenty seconds. A very large paste stalls.

Text formatting survives an edit but cannot be created by one. Live embeds like YouTube or Figma cannot render inside a GPU canvas, so they import as cards with a title, description and favicon and a button to open them in a browser.

There is also a rename I keep deferring, on purpose. The board file extension is load-bearing, and getting it wrong orphans every board on the machine.

The thing I did not expect to learn was how much value there is in separating what I measured from what I believed. My notes carry numbers with their conditions attached, and doing that has caught me more than once claiming a subsystem was fast when what I actually had was a fast case.

A frame budget that only holds at one zoom is a frame budget with an asterisk. The asterisk belongs in the note, and on this page.

Several mind map diagrams with a central node branching out to labelled child nodes
PlatePL-08
TitleMind map
ViewDetail
Mind maps lay themselves out with a tidy-tree pass, so adding a branch reflows the siblings instead of overlapping them.
Four charts on the canvas: a bar chart, a line chart, an area chart and a donut chart
PlatePL-09
TitleCharts
ViewDetail
Tables, kanban and timeline views are built in, because a canvas that cannot hold structured data sends you back to another app.