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.
One value, written once, read three ways.
"quick": { "duration": 0.09, "damping": 1 }
--fd-spring-quick: 90ms linear(0, 0.079, 0.236, 0.403, …, 1);
quick: { omega: 102.59348307168429, damping: 1, duration: 0.09 }
pub const QUICK: SpringConfig =
SpringConfig { omega: 102.59348307168429, damping: 1.0, duration: 0.09 };
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:
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;
}
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:
- JavaScript runs a fixed case and hashes every cell with FNV-1a.
npm run fixtureswrites those hashes tofixtures/.gpui/tests/parity.rsruns the same case in Rust and compares the hash.
Every cell, reduced to one number.
1794412072The stored hash for silk at time 0, Bayer 4x4, 64 by 40 cells.
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.