DEV Community

Cover image for Every Test Passed. Then the UI Got Thrown Out.
Lugiax
Lugiax

Posted on AI-assisted

Every Test Passed. Then the UI Got Thrown Out.

At 1:31 AM, the automated checklist reached its final item.

Eight checks: seven passed, one was marked "record current state," and not a single check was red. By every test report, the feature was done and ready to ship.

Then the user stopped everything.

The verdict was one short sentence that no test suite could argue with: the UI looks bad, redo all of it.

Most developers have been here. The pipeline is green, you feel the relief, and then a person looks at the thing and it feels wrong. The logic is sound. The product still isn't right.

The feature was a native screenshot tool for an open-source terminal app. Press a shortcut, freeze the screen, drag out a region, and draw on top of it. AI agents wrote most of the code; human developers set the direction and made the hard calls.

From the first line of code to the final UI took 10 hours 22 minutes and 27 commits.

Timeline of the session: 10 hours 22 minutes, 27 commits, from 16:25 to 02:47 the next day

Along the way, we ran into three nasty bugs on real hardware that the test suite never caught. Those bugs make up the first half of this story.

The second half is about what happened after that "redo all of it" order, and how we turned a vague complaint into hard numbers.

It all started with an architectural choice that seemed sensible at first, until we threw the whole thing away.

Why we threw away the macOS code to mirror Windows function by function

The tool had to ship on both Windows and macOS.

My first instinct was that each platform could do its own thing internally, as long as the visible features matched.

That idea didn't last long. Windows got the first build, and then macOS followed. A few hours later, the macOS code was thrown away. It was rewritten from scratch to mirror the Windows version function by function.

The spec said the two platforms had to behave the same, item by item. The only allowed difference was the modifier key: Command on macOS where Windows uses the Windows key. Both sides also had to run against the exact same test data and produce the exact same expected outputs.

The dev log puts the reason plainly (translated from Chinese): we want both platforms to produce the same exact value, not two values that are close.

Think of two cooks making the same dish. If each works from memory, you get two meals that taste "about the same." If both follow one recipe and weigh every ingredient on a scale, you get the exact same dish.

None of this had run on a physical machine yet. One of the early commit messages says so (again translated): "No human has pressed confirm on this version yet."

The first run on a real Windows machine broke the undo shortcut immediately.

The undo button on the screen worked fine, but the keyboard shortcut did nothing. The code checked the keyboard state at the exact moment the event got handled. When you tap a shortcut quickly, your fingers are already off the keys by the time the handler runs.

That was the easy one to fix. The next bug on Windows froze the whole app.

A thin white edge that froze the app in an endless loop

The annotation text box sitting over the toolbar, with a loading spinner: the app has hung

A screenshot tool needs text annotations. That means a floating text box sits next to a toolbar with pens, arrows, and other tools.

In some screen layouts, the text box ended up sitting right on top of the toolbar. To keep the toolbar buttons clickable, we cut a transparent hole in the text box where they overlapped.

Clicks worked again, but the cutout left a thin white edge behind. It was the text box's own white background bleeding through.

The obvious fix seemed to be a quick patch. Every time the text box moved, we forced the toolbar to repaint (redraw its pixels on screen) over the overlapping area to cover the white edge.

Instead, the app froze.

On a physical Windows machine, opening the text box sent CPU usage spiking. The program spun in place and locked up. Four tries, four hangs.

The cause was hidden deep inside how Windows draws native controls.

The native text box keeps an old behavior that lets a control paint outside its own bounds. That white edge was the control painting past its own border. Each forced repaint woke that out-of-bounds painting up again, which triggered another repaint request, spinning forever in an infinite loop.

The fix wasn't another patch. We swapped the native text box for one with that out-of-bounds painting turned off.

Once the control could no longer paint past its own edge, the white strip was gone for good.

Before you cover one patch with another, find the control that is drawing where it shouldn't be.

That was Windows. On macOS, a different bug was waiting in the same text box.

The macOS text box that typed "n你好" instead of "你好"

Keys n-i-h-a-o and space, and the text field showing

The next bug showed up on macOS.

In the screenshot text box, using the built-in Pinyin input method, you type n-i-h-a-o and press space to confirm. That should produce 你好 ("hello").

What actually showed up was "n你好". The first letter was stuck in front of the Chinese characters.

Here's some background if you have never worked with an IME (an Input Method Editor, the system tool used to type characters in languages like Chinese or Japanese). While you type Pinyin letters, they sit in a temporary composition buffer. macOS calls this active state marked text. Only when you confirm your selection does that buffer turn into real characters and commit to the field.

The bug came from an auto-grow feature. The text box starts one line tall and gets taller as you type.

The first time the box grew taller, the operating system switched the text layout engine underneath. That switch cleared the marked text state. Because the buffer was suddenly wiped out, the system treated the first letter, n, as already-committed text.

The dev log pinned it down (translated): "Didn't happen in earlier builds. Regression from the dynamic height change."

A regression is when previously working code breaks because of a new change.

The fix was to lock the text box to one layout engine from the moment it was created, and never let it switch. After that, we tested every font size on a real Mac, and 你好 came out clean.

The interesting part is that this bug never showed up in our automated tests. They run without real window management, and that's where the bug lives.

Anything that touches IME state, marked text, or window focus must be typed into on a physical machine.

Text input was fixed. The scrolling capture was next, and it had its own trap.

Why the scrolling screenshot refused to move past the first screen

Two frames before and after scrolling three lines: the window border column on the right never moves, and the scrollbar thumb shifts a little

The third problem was the scrolling screenshot, also called a long screenshot.

The concept is straightforward. The user scrolls down, the capture engine grabs frames quickly, compares each new frame to the last one to find where they overlap, and appends the newly revealed part to the bottom.

To find the overlap, we used a strict pixel diff (a row-by-row pixel comparison). If row X of the previous frame exactly matches row Y of the current one, the algorithm knows how far the page moved.

On a real machine, no matter how far you scrolled, the output image stayed stuck at one screen tall. Not a single new frame got merged.

The culprit was a column of pixels that never moved at all: the window border.

The page content scrolled, but the window border at the edge of the selected region stayed put. Every row in the comparison had a few border pixels that could never match. In a strict comparison, one mismatched pixel rejects the entire row. Every row got rejected, so the engine concluded there was no overlap anywhere.

The fix follows from that: before comparing frames, drop the pixel columns that stay completely still from top to bottom, so they don't count toward the match.

There's a trap in that rule. "Still" must mean the column is exactly identical all the way down, not just mostly identical. Take line numbers in a code editor. They look almost the same from row to row, but their small shifts are the best signal you have for tracking how far the page moved.

With the border handled, the scrollbar broke the algorithm next. The scrollbar thumb doesn't scroll with the page, and it doesn't stay still either. It moves a little, at its own separate ratio.

So the engine received a second rule. If a pixel column changes in at most two segments, and the changed part is less than half its height, it is treated as background noise and left out of the comparison.

We also added a safety rule for failure. If the user scrolls so fast that frames get dropped, the engine throws away the broken frame and displays a "slow down" message. It never forces a stitch.

Before you ask an algorithm to compare two things, cut out everything that shouldn't be part of the comparison.

That should have been the end of the night. It wasn't.

Turning "it looks bad" into hard pixel measurements

Bar chart of filled pixels per toolbar icon: mosaic 732, undo 88, a ratio of 8.3x

With those three bugs resolved, testing on real hardware went green. That brought us right back to 1:31 AM.

All 8 checks passed. Buttons responded, text input worked, and long screenshots stitched cleanly. QA had nothing left to mark as failed.

Then came the user review. The complaints were clear:

The icons didn't feel balanced in visual weight. The gray plate behind the selected tool looked cheap. The dots used to pick the font size didn't make sense. And the opaque text box covered what was underneath it.

Editing code against feedback like that is guesswork.

So we stopped writing code and started measuring pixels.

We used the app's own rendering code to draw each toolbar icon on its own, and we counted how many pixels it filled.

The hard numbers explained the imbalance immediately.

The lightest icon (undo) and the heaviest icon (mosaic) differed in filled area by 8.3x. Picture a shot put and a balloon sitting side by side in the same toolbar.

The shapes were misaligned as well. The long-screenshot icon was tall and thin like a pole, sticking up above the baseline. The arrow in the selection tool wasn't centered in its 16x16 grid; it leaned noticeably to the left.

The worst find was where the graphics came from. 5 icons were not drawn from vector paths at all. They were plain characters pulled from the host operating system's fonts. Windows and macOS ship different system fonts, so those 5 icons looked visibly different on the two platforms.

"It looks bad" was no longer a matter of opinion. It was now a table of pixel measurements. That table became the acceptance criteria for the UI rework.

When someone says something looks wrong and you can't argue with it, the first step is to find a way to measure it.

We had the data. But before we touched any code, we changed how we worked.

Locking in the mockup before writing a single line of native code

The final toolbar mockup, with the selected tool marked by a thin blue ring

This time, no AI was allowed to touch the code.

We had already seen what happens otherwise. You build the UI in code, compile it, and show it to the user. If the visual direction is wrong, all the cross-platform work gets thrown out.

So we went mockup-first.

We built high-fidelity mockups using web technologies. A browser mockup isn't the native code that ships to users, but you can iterate on it more than ten times faster than on compiled code.

From the "redo all of it" order to the new UI spec being committed took 76 minutes. In that time, the mockup went through three major versions.

Each small request got settled directly inside the mockup: hover feedback, replacing the confusing font-size dots with a small and a large letter "T," and switching a solid border to a dashed one so it blocked less of the view.

Making those changes in native code would have meant editing and recompiling both platforms dozens of times. In the mockup, it was just dragging elements and changing values.

Any disagreement about looks that can be settled in a mockup should never make it into the code.

The visual design was locked in. Now we had to build it in native code without letting the two platforms drift apart by a single pixel.

One data file, two platforms, zero pixel difference

One JSON data file feeding generated Rust constants for Windows and Swift constants for macOS

With the design settled, the engineering question was how to guarantee that the two platforms could never drift apart, even by a single pixel.

The lesson from the old version was clear. Each platform had its own separate implementation, and 5 icons ended up looking different on each.

This time, we established a single source of truth.

A single source of truth means one file that every platform reads from. Every UI parameter (color values, spacing, and Bézier control points) went into one standalone data file.

The Windows and macOS UI code was no longer written by the AI agents. Instead, a custom script reads that data file and generates the UI code for each platform automatically.

It works like casting parts from a single mold: parts from the same mold cannot drift past tolerance.

Rendering received the exact same treatment.

The native drawing APIs on Windows and macOS handle anti-aliasing slightly differently. Anti-aliasing is how graphics systems smooth the jagged edges of lines. Because the two systems drew lines differently, we stopped using the built-in line drawing and ran the same basic rendering code on both platforms.

To check our work, we pulled the outlines of all 60 icons as rendered on each platform and compared them pixel by pixel.

They matched exactly, with zero pixel difference.

The code was unified, and the icons were identical. Which brings us back to what that green test suite actually meant.

What a green test suite can and cannot promise

Boil those 10 hours 22 minutes down, and you get a clear lesson: automated tests are very good at catching logic bugs, but they are blind to how an app feels to use. A lot of what makes software feel well made sits outside the green checkmarks.

What automated tests can promise What a human still has to check
Pipeline is green, no errors Is a test case missing? Did a real problem quietly get downgraded to "record current state"?
Unit tests pass 100% Has it run on a physical machine? Has a person's hand gone through the interaction?
The visual glitch (white edge) is covered up in code Was the underlying cause removed, or just covered with another patch?
Frame comparison failed, logic stopped Did the algorithm drop the noise that never belonged in the comparison?
The feature works end to end Does it look and feel right? Was there a high-fidelity review before coding?
Both platforms are done Were both generated from the same data, or did each side settle on its own version?

Think back to 1:31 AM.

The all-green checklist didn't lie. It checked everything it was built to check. It just had no item for "does this look good."

Next time someone tells you all tests pass, ask one more question: beyond the code being correct, who decided how it should look and feel, and at what point?

The screenshot tool, new toolbar included, has shipped in Polter, a terminal for macOS and Windows built on Ghostty's rendering: https://github.com/Lugia123/polter

Top comments (1)

Collapse
 
lugia123 profile image
Lugiax •

Author here. This is a write-up of one long session building the screenshot tool in Polter, a terminal for macOS and Windows. AI agents wrote most of the code; I set the direction and made the calls.

Two things I didn't expect:

  • All three bugs that mattered (a repaint loop in a native Windows text box, an IME regression on macOS, and a long-screenshot stitcher defeated by the window border) only showed up on real machines. Our automated tests never touched real window management, so they couldn't see them.
  • "The toolbar looks bad" turned out to be measurable. Counting filled pixels per icon showed an 8.3x weight gap, and 5 icons were system font glyphs that rendered differently on each OS. After that we moved the UI into one data file that generates both the Rust and Swift constants.

Happy to answer questions about the cross-platform setup or the agent workflow.