One UI library, two stacks

Building a UI library for the plain web and native Rust: shared tokens, engines written twice, and tests that compare them cell by cell.

In this note

On the web, a button is a <button> element with a class. In GPUI, the Rust UI framework behind the Zed editor, it's a struct that renders itself. The two have no renderer, language, or layout engine in common. So what does it mean to call them the same component?

That question sits underneath fluidither, a component library I've been building with a printed look: ink on paper, dithered shading where most interfaces use a flat tint, and springs for anything that moves. It has two targets, plain HTML, CSS, and JavaScript with no framework, and a Rust crate on GPUI. Keeping them alike comes down to deciding exactly what has to match, then picking a different way to enforce each part.

Sort what has to match

“The same” hides three different promises, and each needs its own mechanism. Treating them as one problem is how a cross-stack library ends up either sharing too little or fighting both platforms.

Layer The promise What keeps it true
Design values Identical colors, sizes, and springs Generated from one source file
Engines Identical dither cells and spring paths Written twice, then compared by tests
Components The same job, built the way each platform expects Differences written down, not hidden

One spring, three files

The first layer is the easy one to automate. Colors, sizes, springs, pixel glyphs, and dither tables all live in tokens/, and a single command writes the CSS, the JavaScript tables, and a Rust module. A spring shows why that's more than copying numbers: people think in durations, the physics needs a frequency, and CSS can't run physics at all.

Tokens / one source

One value, written once, read three ways.

tokens/tokens.json
"quick": { "duration": 0.09, "damping": 1 }
tokens.css
--fd-spring-quick: 90ms linear(0, 0.079, 0.236, 0.403, …, 1);
core/tokens.js
quick: { omega: 102.59348307168429, damping: 1, duration: 0.09 }
generated.rs
pub const QUICK: SpringConfig =
    SpringConfig { omega: 102.59348307168429, damping: 1.0, duration: 0.09 };
Generated values from the repository, with the CSS curve shortened. The frequency is solved from the duration, and it's the same number in both languages.

The generator solves for the frequency whose response has settled by the stated duration. CSS gets that response sampled into a linear() curve, while JavaScript and Rust get the frequency itself and step the exact solution, so a target can change mid-flight without a jump. The generated files are checked in, which means nobody needs the generator to copy the web source or depend on the crate, and nobody edits them by hand.

The part a generator can't write

Values are data. The code that decides which cells get ink is logic: gradients, fields, charts, the world map, scramble text, dates, and springs. The web target is plain JavaScript and the other is Rust, so all of it exists twice, and each engine file names its twin at the top.

Trigonometry is the first trap. A field needs something sine-shaped for every cell, but neither language promises the same bits: ECMAScript calls Math.sin an implementation-approximated value, and Rust's sin documents unspecified precision. A difference in the last bit is invisible until it pushes a cell across its threshold. So the fields skip sin entirely and use a curve built from plain arithmetic, which both languages compute identically:

fields.js
export function wave(t) {
  const turn = t - Math.floor(t);
  const half = turn < 0.5 ? turn * 2 : (turn - 0.5) * 2;
  const shape = 4 * half * (1 - half);
  return turn < 0.5 ? shape : -shape;
}
fields.rs
pub fn wave(t: f64) -> f64 {
    let turn = t - t.floor();
    let half = if turn < 0.5 { turn * 2.0 } else { (turn - 0.5) * 2.0 };
    let shape = 4.0 * half * (1.0 - half);
    if turn < 0.5 { shape } else { -shape }
}

Compare the cells, not the screenshots

Two hand-written engines drift unless something notices, and screenshots are a poor way to notice. GPUI's test platform has real layout and input but draws nothing, and captures from a real window use the display's P3 color space, so correct values can still look different from Chromium. The useful comparison sits below the pixels. In this arrangement, one engine becomes the reference:

  1. JavaScript runs a fixed case and hashes every cell with FNV-1a.
  2. npm run fixtures writes those hashes to fixtures/.
  3. gpui/tests/parity.rs runs the same case in Rust and compares the hash.
Parity / one real fixture case

Every cell, reduced to one number.

The silk field at time zero, as 64 by 40 dither cells Soft diagonal folds drawn only in square cells of ink. This is the actual output of the JavaScript engine for a case stored in the project's fixtures.
1794412072The stored hash for silk at time 0, Bayer 4x4, 64 by 40 cells.
Real output from the JavaScript engine, not an illustration. Rust has to produce the same hash from its own code, so one changed cell fails the test.

Each field is checked at two moments, so a bug that only appears in motion still fails. There is a limit worth keeping in view: a fixture records what JavaScript did, not what's right. If both engines share a mistake, the test passes. Parity catches drift between the stacks, and judging whether the result looks good stays a separate job.

Where the stacks refuse to match

The third layer is where a shared library has to stop pretending. On the web, a button really is a <button> and a dialog really is a <dialog>, so the keyboard and accessibility come from the browser, and custom elements appear only where the platform has nothing. gpui 0.2.2 has no accessibility API, so VoiceOver can't see those components at all. No library code can fix that, which makes it a fact to state plainly: the GPUI target shouldn't be used alone for anything that must be accessible.

Even drawing the same cells takes different routes. The web paints them into a canvas as a mask and lets the element's CSS color fill them, so themes need no special handling. On GPUI, drawing each cell as a quad took 40.38 ms to paint at 93,600 cells on an M1 Pro, against 1.83 ms for a single image, so images became the default and quads stayed for small hover areas. The cells match; the path to the screen doesn't have to.

Testing has an edge too. The web target has run in Chromium on macOS, and the GPUI target on macOS with Apple Silicon. Firefox and Safari haven't been run, Linux and Windows haven't been built, and no one has tried it with a real screen reader. A support page saying so is part of the library, because a promise of sameness is only worth what it has been checked against.

Share the decisions, not the runtime

So the two buttons are the same component without sharing a line of rendering code. They read the same values, shade with the same cells, and move on the same springs, while each stays a normal citizen of its own platform. If you're starting a library across stacks, begin with the table rather than the code: decide which layer each promise belongs to, generate everything that's only data, and pick one reference engine, hashing its output before the second engine exists. That way, the first line of the second stack already has something to be wrong about.

Keep this article in your starred list.

↑ ↓ to explore · Enter to open