anchor: .bottom) and read as legs. This is the sequel to why your vibe-coded app looks worse, and the fix that worked — render and look before you argue, grep call sites before you touch a contract, and pre-register what would prove the choice wrong.A while back I wrote about why your vibe-coded app looks worse than you expect. That post diagnosed the cause. This one is the fix that actually worked, on a real job: redesigning the mascot in my trip expense-splitting app.
The mascot is the face of the app. It shows up in more than twenty places — onboarding, settings, the stats screen, the map, the diary, the settlement report, and five little mini-games. And it was nothing. One circle did double duty as head and body. No legs. No hands. No eyebrows. One X for an eye. Visually its identity was zero: a tinted circle. I knew it was bad. What I could not do was say what to change.
I kept talking myself in circles about it, and so did the AI I was pairing with. Rounder? Add a hat? Bigger eyes? Every sentence sounded reasonable and none of them moved the decision. At some point I noticed what was actually going on: this was not a shortage of information. Nobody needed to go fetch a fact. It was a shortage of fidelity. A visual decision cannot converge in prose, because prose is not the medium the decision lives in.
That is the tell. When a discussion loops and more words don't help, you don't need more analysis — you need a picture. So I stopped arguing and built prototypes.
The rule I gave myself: make variants that are structurally different, not palette swaps. Different silhouette, different anatomy, a different device carrying the identity. Repainting the same shape in different colors teaches you nothing. Three genuinely different creatures force a real choice.
I built three and rendered every one as an action sheet so I could look at them side by side:
Then I looked. Not at the code — at the pictures.
The first render of the wallet had a problem you would never find by reading the diff: the arms read as legs. The little guy looked like it had four legs and no arms.
The cause was one line. The arm's rotation anchor was set to anchor: .bottom — the fingertip end — so the arm rotated around the hand instead of the shoulder. The shoulder was swinging around a pinned fingertip. In code that is a completely ordinary, plausible-looking value. .bottom is a normal anchor. Nothing about the source says "this will look like a leg." You cannot see it by reading. You can only see it by rendering.
This is the whole point, and it is why the essay is called what it's called. The AI that wrote that line could never have seen the result. It emitted syntactically fine SwiftUI, the type checker was happy, a human scanning the diff would nod it through — and the thing on screen was wrong. An AI writing UI code is blind to its own output. So are you, in a review. The only cure is to render and look.
While I was in there, I nearly caused a regression. The mascot's initializer takes an expression parameter, and I assumed expression meant facial expression. Since I was rebuilding the face anyway, I went to delete it.
Before I did, I grepped the call sites. expression was not the face at all. It was a screen-context badge channel — a small marker for language, map, stats, diary, spend, or Pro — and eight call sites were passing it. Deleting it would have silently broken a feature in eight places that had nothing to do with the face I was changing.
The lesson is boring and it keeps paying rent: read every call site before you change a parameter's contract. A name is a guess about meaning; the call sites are the meaning. I kept the badge, and decoupled it from the body's wobble so both read more clearly.
I picked B, the wallet. The reason is one sentence: the silhouette alone says "money app," and for a mascot on an expense app, saying what the app is is the whole job. A tinted circle said nothing. A wallet says it before you've read a single word.
Emotion ended up with three independent channels: the face (a MascotMood type with seven states — eyebrows, eyes with a highlight for a bit of life, mouth, a cheek touch), the banknotes popping with the mood, and squash-and-stretch on the whole body. The old mascot had one channel, a mouth shape.
The part I'm quietly happy about: it dropped in with the interface unchanged. The initializer is still AnimatedMascot(tint:direction:action:size:expression:speed:externalPhase:). All twenty-plus call sites stayed exactly as they were, and the build went straight to BUILD SUCCEEDED. A redesign that reads as a rewrite from the outside but touches one file on the inside.
Here is the before and after:
| Old | Wallet | |
|---|---|---|
| Silhouette | one circle (head = body) | wallet + flap + banknotes + limbs |
| Legs and hands | none | present (walk actually walks) |
| Expression | mouth only, no eyebrows | seven-state mood with eyebrows |
| Eyes | white plus pupil | plus a highlight |
| Identity | none | wallet = money |
| Emotion channels | one | three |
Liking a render is not proof. Before I committed, I wrote down what I would observe if the choice were wrong — falsifiers — and then went and checked them in the real app: built and run on the simulator (iPhone 17 Pro Max, iOS 26.3) for the big screens, plus a headless size ladder rendering the production mascot at 28, 36, 44, 64, and 96pt across idle, wave, stunned, and laugh.
Two of three either didn't fire or were survivable, and the one that's open is open honestly, with a stated plan. That's a decision I can defend later, because I wrote down in advance what would sink it.
One more thing, because it's a good reminder. While checking all this in the simulator, every emoji rendered as a tofu box — the little "?" rectangle. My first instinct was that I'd shipped a bug.
I hadn't. A grep showed zero custom fonts anywhere; every emoji goes through a plain Text on the system font. That means on a real device it falls back to Apple Color Emoji and renders fine. The tofu was an iOS simulator runtime artifact — the emoji font simply wasn't loaded in that build of the simulator — not a product bug. The mascot itself, being pure SwiftUI shapes, was fine at every size.
The lesson: the surface you verify on can lie to you too. One render told me the truth about the arms and a different render told me a falsehood about the emoji. You have to know which surface you're looking at.
Stripped down, here's the loop that came out of this:
The prequel explained why vibe-coded UI comes out worse than you'd think. This is the answer: stop reviewing code you can't see, and start rendering pictures you can.
Q. Why does AI-generated UI code look wrong even when the code reviews clean?
Because an AI writing UI code cannot see the picture that code produces, and neither can a human scanning the diff. Rendering happens at runtime while a review only sees source, so syntactically fine, type-checked SwiftUI can still draw the wrong thing. The only reliable check is to render the interface and look at it, not to read the code more carefully.
Q. How many design variants should I prototype, and how different should they be?
Three, and structurally different rather than palette swaps. Variants that differ only in color teach you almost nothing, while variants with different silhouettes, anatomy, and identity devices force a real decision. Render them side by side and choose by looking, not by arguing.
Q. How do I stop an AI refactor from breaking a parameter that is used elsewhere?
Grep every call site before you change or remove a parameter. A parameter's name is only a guess about its meaning, but its call sites are its real contract. I nearly deleted a parameter I assumed was a facial expression, and the call sites showed it was actually a screen-context badge used in eight places.
Q. What is a falsifier for a design decision?
It is a concrete, observable outcome you write down in advance that would prove the choice wrong. Instead of saying you like a design, you pre-register something like 'if the mascot is unreadable below 32pt, this was the wrong pick,' and then check it at real sizes. Liking a render is not evidence, but a falsifier that survives or fires is.
Q. Why did a SwiftUI rotation anchor make the arms look like legs?
The arm's rotation anchor was set to the fingertip end with anchor: .bottom instead of the shoulder, so the arm swung around the hand and read as a leg. The value looks perfectly ordinary in source and is invisible in a review, and it only becomes obvious when you render the animation and watch it move.