All posts

· 8 min read

How we check that your export matches your preview

How verve runs exported Motion, GSAP and CSS in a real browser and measures it against the editor's own engine, and the bugs that turned up.

The preview in verve runs on our own spring and keyframe engine. Your export runs on something else: Motion, GSAP, or the browser's CSS animations. Getting from one to the other is a translation, and a translation can read fine and still say the wrong thing.

So we don't take the exporter's word for it. A gate renders the exported code in a real browser and measures it against the engine, and any disagreement bigger than a small tolerance fails the run. The homepage shortens all of this to "We check." This is the long version.

Code that looks right and moves nothing

Before the gate covered GSAP, its export had unit tests throughout and was broken three ways.

The worst was the shape of keyframes. GSAP wants the percent stops on the outside with the properties inside each one, and the export wrote it the other way round:

// What GSAP reads
keyframes: { "0%": { x: -240, opacity: 0 }, "50%": { /* … */ } }

// What the export wrote
keyframes: { x: { "0%": -240, "50%": /* … */ } }

GSAP doesn't recognize the second shape and doesn't complain about it, so the tween runs for its full duration and moves nothing. Every GSAP export with a spring, a bezier curve or more than two keyframes did this. The first time the gate ran them, 16 of 18 fixtures came back at 100% error.

The other two were quieter. The export mapped our easing presets, which are CSS bezier curves, onto GSAP's power eases, a different family of curve: easeOut and power2.out are 21.6% of the animation's range apart at their furthest. The unit tests had been written to match that mapping, so they passed. And every filter went into one object key, { filter: 12, filter: 1.8, filter: 0 }, where JavaScript keeps the last.

None of it threw an error or logged a warning. You only see this kind of bug by running the code and looking at what moves.

What the gate measures

The gate works from a fixed set of documents: 25 single elements and 12 scenes. Each one pins down something an export could get wrong and carries a sentence saying what, like "a spring the timeline cuts mid-overshoot must not run on to rest."

For every document, the gate runs each export in headless Chromium and reads back what the browser resolved: the computed transform matrix, opacity, filters and colors, and for an element on a motion path, where it is and which way it's turned.

The engine's side goes through the browser too. Its values are written onto a plain element and read back the same way, so both sides share the same CSS parsing and matrix math. Composing the engine's matrix ourselves would mean a second implementation of transform, which would hide the bug most worth catching: transforms applied in a different order.

The gate compares matrices because different sets of properties can produce the same matrix, and the same matrix is the same pixels. The score is the worst difference anywhere in the animation, as a percentage of that property's range. Two pixels of drift on a 4px nudge and on a 400px slide aren't the same bug.

Scrubbed or watched

CSS and GSAP can both be paused and put at an exact time. The gate pauses the CSS animation and sets currentTime, or pauses the GSAP tween and calls seek(), at 41 evenly spaced moments. Nothing depends on timing, so both are held to a tight tolerance.

A Motion export is a component that starts animating when it mounts, with no playhead to set from outside. So the gate mounts it, lets it play, and reads the element on every frame from inside Motion's own frame loop. Motion computes some values itself and hands others to the browser, so each reading carries both timestamps and each property is matched to the clock that produced it. A live start never lands exactly on zero, so the gate also searches out a small start offset per property, at most 40ms either way, before comparing. That's why Motion gets more room.

ExportHow it's readToleranceWorst in the last run
CSSPaused, currentTime set per sample0.6%0.46%
GSAPPaused, seek() per sample0.6%0.53%
MotionPlayed, read every frame2%1.29%

Each tolerance sits just above what that export achieves, so there's little slack for a regression to hide in. The last run passed all 111 checks, one per document per export. Five are exemptions, and they print their reason on every run.

The worst Motion figure isn't from an export. Paste a component that already animates with Motion and verve reads its animation into tracks, and one scene plays that component's original Motion code against the tracks we read.

What a single element couldn't show

Every fixture used to be a single element. That's how a fourth GSAP bug got through while the gate reported 63 of 63.

The editor opens on a card whose rows rise and fade in on a stagger. Its GSAP export aimed the stagger at the card's direct children, but the rows sit one level deeper, so it animated the card's title and list container and left the rows where they were. It also dropped the card's own entrance. We found it while building code highlighting for the curve panel, which couldn't find a single line of GSAP for the card's spring.

So the gate runs scenes now: real markup, children found by their path through the tree the way the exports find them, and every element scored, with the worst one named. To make sure scenes could fail, we put those bugs back on purpose, and every one failed.

When the gate was wrong

A gate can be wrong in two directions: failing an export that's fine, or passing while it measures nothing. Ours has done both.

The first was Motion's alignment. The offset search used to pick its offset by the number it reported, the single worst error. On a busy machine, one late frame near the steepest part of a curve became that worst error, and the offset followed it. Five runs in a row on one fixture, a spring cut mid-overshoot, scored anywhere from 0.39% to 4.73%, and the last one failed. CSS held at 0.21% throughout, so only the measurement had changed.

Now the offset is chosen by RMS error across every frame, where one bad frame barely registers, and the verdict is still the worst error at that offset, bad frame included. The tolerance didn't move. The next five runs passed, between 1.01% and 1.41%, and the typical figure went up a little. That was the fix working: the old search had picked whichever offset flattered the number it printed.

Underneath was a second cause: we'd timed each reading with our own clock, and its gap from Motion's frame clock wandered by a few milliseconds with machine load. Reading from inside Motion's frame loop fixed it, and chasing it turned up a real export bug. The Motion export ramped a spring cut off mid-flight across its last 3ms, where the engine snaps, and could be 20px off there.

A check that passes on nothing is worse, because it never tells you. The harness once reported a perfect engine curve that was really one frame repeated. A bouncy spring pushed blur below zero, the browser quietly rejected blur(-4px), and the element kept its last accepted value for as long as the undershoot lasted. Every write now clears the property first and fails loudly if the browser refuses the new value.

When we built a command-line version of the gate, we tested it by breaking the exporter on purpose. The first attempt broke a line the test scene never runs, so the check passed and would have vouched for the tool while measuring nothing. Breaking a line the scene does run took it to 5 of 10 passing, 8.00% error and a failing exit code. We don't count a check as working until we've watched it fail.

Reading a failure

A failure prints the fixture's sentence, which export failed, the moment the error peaked, and both values at that moment: what the engine wanted and what the browser rendered. Most of the diagnosis happens before you look at the percentage.

The sentence tells you which behavior broke. One export failing is almost always that exporter, and all three failing at once is the engine, or a fixture whose expectations moved. The moment tells you what kind of wrong it is. At t=0 it's a starting value. At the end, it's the target or the point where a spring counts as settled. In the middle, it's the curve: the wrong ease, or stops spaced too far apart.

A number that's far too big usually means leftover state. An early run of the command-line version reported 7071% error. GSAP writes transforms into the element's inline style and caches its own state on the node, and killing the tween clears neither, so a command that reused one element started each fixture in the previous one's pose. 0.7071 is sin 45°, a leftover rotation on a matrix value the engine expected to be zero. A value that doesn't move is scored against a floor of 0.01, and 0.7071 over 0.01 is 7071%. CSS looked clean the whole time only because a CSS animation outranks an inline style. Every measurement gets a fresh element now.

Where it stops

It runs in Chromium. Safari and Firefox aren't measured.

It checks the exporters against the 37 documents we wrote. Nothing runs this comparison on your document when you export it.

It only measures values that animate. Settings that stay put, like transform origin and perspective, are covered by unit tests on the exported text, which is weaker evidence. And it measures playback on the timeline, so scroll-linked exports, view transitions, interaction states and reduced-motion variants aren't part of it.

Motion's timing is only as strict as that 40ms window: a Motion delay off by less than that would line up and pass.

Three fixtures are exempt in GSAP. GSAP composes transforms in its own order, rotation before scale, and doesn't let you change it. With a non-uniform scale, a skew or a 3D rotation, that order produces a different matrix, so the gate skips those three and prints why on every run.

Try it

The editor opens on that same card. Drag its spring until it feels right, export it as Motion, GSAP or CSS, paste it into your project and play it next to the preview. No account needed. If the two don't match, you've found a fixture we don't have yet.